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.
@@ -23,12 +23,99 @@ module Permittable
23
23
  # this array in place and ActionDispatch reads the same object per
24
24
  # request, so a name registered when a controller loads is seen by the
25
25
  # next request. It is the array, not a snapshot, that has to be fed.
26
+ #
27
+ # ActiveRecord is a second sink: see FilterAttributesFeed, which also
28
+ # explains why it is fed here and installed by the initializer below.
29
+ feed = filter_attributes_feed
26
30
  ::Permittable.on_sensitive_parameter do |name|
27
31
  filters = app.config.filter_parameters
28
32
  filters << name unless filters.include?(name)
33
+ feed.add(name)
29
34
  end
30
35
  end
31
36
 
37
+ # AFTER ActiveRecord's own hook, not before: both run when ActiveRecord::Base
38
+ # loads, in the order they were registered, and that one does
39
+ # `filter_attributes += config.filter_parameters`. Running first, the feed
40
+ # would add each name and that copy would add it again as a String (it holds
41
+ # Strings whenever precompile_filter_parameters is off, as on Rails 6.1).
42
+ initializer "permittable.filter_attributes",
43
+ after: "active_record.set_filter_attributes" do
44
+ feed = filter_attributes_feed
45
+ ::ActiveSupport.on_load(:active_record) { feed.attach(self) }
46
+ end
47
+
48
+ # The second sink for sensitive names: ActiveRecord::Base.filter_attributes.
49
+ #
50
+ # It is a snapshot that never sees a name registered after it loaded. Its
51
+ # `filter_attributes` is copied from config.filter_parameters exactly once,
52
+ # when ActiveRecord::Base loads, and in a real app that is BEFORE any
53
+ # controller registers anything: the first controller's own `model: Person`
54
+ # loads it. So the copy holds only the proc the initializer appended, which
55
+ # cannot redact an Integer in the SQL bind log (`[["pin_code", 4321]]`, where
56
+ # #inspect, handed an already-inspected String, was fine). Each name is
57
+ # therefore also fed to the model base class directly. The setter drops the
58
+ # memoized inspection filter, and only names that are missing are added, so
59
+ # the list holds one entry per name however often a reloaded controller
60
+ # registers it.
61
+ #
62
+ # An app that ASSIGNS `filter_attributes = [...]` (an initializer, or a
63
+ # to_prepare block, which runs on every reload) rather than appending to it
64
+ # discards everything fed in so far, and a name that was registered only once
65
+ # would never come back. So every registration restores EVERY name registered
66
+ # so far that is missing, not just its own: the next contract to load, or the
67
+ # next reload re-declaring one, heals the list. (A to_prepare of the gem's own
68
+ # would not help: railtie blocks run before the app's, so the app's
69
+ # assignment would still come last.)
70
+ #
71
+ # The read-modify-write is done under a lock, as two threads loading
72
+ # contracts at once would otherwise each write back a list that lacks the
73
+ # other's name. Nothing inside the lock calls back into the sink or loads
74
+ # ActiveRecord, so it cannot deadlock.
75
+ #
76
+ # ActiveRecord is hooked ONCE for the process, in #attach, rather than once
77
+ # per name: on_load runs the block at once when ActiveRecord is already
78
+ # loaded, waits for it otherwise, and never fires in an app without it, where
79
+ # a hook per registration would queue forever across every code reload.
80
+ class FilterAttributesFeed
81
+ def initialize
82
+ @names = {}
83
+ @base = nil
84
+ @lock = Mutex.new
85
+ end
86
+
87
+ # Called with ActiveRecord::Base once it has loaded.
88
+ def attach(base)
89
+ @lock.synchronize do
90
+ @base = base
91
+ restore
92
+ end
93
+ end
94
+
95
+ def add(name)
96
+ @lock.synchronize do
97
+ @names[name] = true
98
+ restore if @base
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # The caller holds the lock.
105
+ def restore
106
+ missing = @names.keys - @base.filter_attributes
107
+ @base.filter_attributes += missing unless missing.empty?
108
+ end
109
+ end
110
+
111
+ # Shared by the two initializers, which Rails runs as instance_exec on this
112
+ # one object. Memoized rather than built in the first, so the second does not
113
+ # depend on the order Rails settles on for initializers that name no target.
114
+ def filter_attributes_feed
115
+ @filter_attributes_feed ||= FilterAttributesFeed.new
116
+ end
117
+ private :filter_attributes_feed
118
+
32
119
  rake_tasks do
33
120
  load File.expand_path("tasks/openapi.rake", __dir__)
34
121
  load File.expand_path("tasks/generate.rake", __dir__)
@@ -96,7 +96,10 @@ module Permittable
96
96
  end
97
97
 
98
98
  def matching(regexp)
99
- @expected[:format] = regexp
99
+ # `format: "email"` names a preset exactly as `format: :email` does
100
+ # (see resolve_format!), so the matcher reads a String the same way;
101
+ # as a Symbol it also reports the preset the same way in a failure.
102
+ @expected[:format] = regexp.is_a?(String) ? regexp.to_sym : regexp
100
103
  self
101
104
  end
102
105
 
@@ -364,8 +367,9 @@ module Permittable
364
367
  "expected the field to be #{expected}, but it is #{actual}" unless actual == expected
365
368
  end
366
369
 
367
- # `matching(:email)` asserts the preset by name, `matching(/re/)` the
368
- # Regexp itself.
370
+ # `matching(:email)` (or `matching("email")`, normalized to a Symbol by
371
+ # `matching`) asserts the preset by name, `matching(/re/)` the Regexp
372
+ # itself.
369
373
  def format_mismatch(field, expected)
370
374
  return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
371
375
  return if field[:format_name] == expected
@@ -374,7 +378,21 @@ module Permittable
374
378
  end
375
379
 
376
380
  def default_mismatch(field, expected)
377
- option_mismatch(field, :default, expected) unless field.key?(:default) && field[:default] == cast_default(field, expected)
381
+ option_mismatch(field, :default, expected) unless field.key?(:default) && same_default?(field, expected)
382
+ end
383
+
384
+ # A :json default is stored as authored, but the action reads it as
385
+ # a HashWithIndifferentAccess, String-keyed all the way down. The
386
+ # declaration's spelling, `with_default(theme: "dark")`, and the
387
+ # action's, `with_default("theme" => "dark")`, name the same default,
388
+ # so both sides are read that way before comparing.
389
+ def same_default?(field, expected)
390
+ declared = field[:default]
391
+ if field[:kind] == :json && declared.is_a?(Hash) && expected.is_a?(Hash)
392
+ ActiveSupport::HashWithIndifferentAccess.new(declared) == ActiveSupport::HashWithIndifferentAccess.new(expected)
393
+ else
394
+ declared == cast_default(field, expected)
395
+ end
378
396
  end
379
397
 
380
398
  # A contract stores its `default:` as the field reads it — cast,
@@ -483,8 +501,11 @@ module Permittable
483
501
  end
484
502
  end
485
503
 
504
+ # An anonymous controller class has no name, and a Contract instance
505
+ # never does, so the fallback names the kind of subject it is.
486
506
  def subject_name
487
- (@subject.respond_to?(:name) && @subject.name) || "the controller"
507
+ (@subject.respond_to?(:name) && @subject.name) ||
508
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
488
509
  end
489
510
 
490
511
  def path_label
@@ -616,15 +637,33 @@ module Permittable
616
637
  attr_accessor :params
617
638
  end
618
639
  mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
640
+ action = @action || rule[:actions].first || "call"
641
+ label_instrumentation(host, action)
619
642
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
620
643
  instance = host.new
621
644
  instance.params = @params
622
- action = @action || rule[:actions].first || "call"
623
645
  violations = instance.permittable_violations(action)
624
646
  result = violations.empty? ? instance.permitted_params(action) : nil
625
647
  [violations, result]
626
648
  end
627
649
 
650
+ # A rejection instruments invalid_parameters.permittable exactly as a
651
+ # request would. The host is anonymous, so without this the payload
652
+ # carried controller: nil, action: nil — a subscriber following the
653
+ # README's shape (`payload[:controller].tr("/", ".")`) raised out of
654
+ # the matcher. Label it the way the subject itself would: a
655
+ # controller by its path (or name, or "the contract" when anonymous —
656
+ # an anonymous controller's controller_path is nil), a Contract as
657
+ # Contract#call does.
658
+ def label_instrumentation(host, action)
659
+ label = if @subject.is_a?(Permittable::Contract) then "Permittable::Contract"
660
+ elsif @subject.respond_to?(:controller_path) then @subject.controller_path || subject_name
661
+ else subject_name
662
+ end
663
+ host.define_method(:permittable_controller_name) { label }
664
+ host.define_method(:permittable_action_name) { action }
665
+ end
666
+
628
667
  # Mirrors Permittable::Contract's own host override verbatim (see
629
668
  # lib/permittable/contract.rb): standalone input has no router and no
630
669
  # request, so nothing is exempt from unknown: checking, top level or
@@ -666,8 +705,11 @@ module Permittable
666
705
  violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
667
706
  end
668
707
 
708
+ # An anonymous controller class has no name, and a Contract instance
709
+ # never does, so the fallback names the kind of subject it is.
669
710
  def subject_name
670
- (@subject.respond_to?(:name) && @subject.name) || "the contract"
711
+ (@subject.respond_to?(:name) && @subject.name) ||
712
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
671
713
  end
672
714
 
673
715
  def action_label
@@ -3,7 +3,9 @@
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,
8
+ # # for the actions it defines
7
9
  # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
8
10
  namespace :permittable do
9
11
  desc "Draft Permittable contracts from models and existing permit calls"
@@ -15,23 +17,26 @@ namespace :permittable do
15
17
  bases << ActionController::API if defined?(ActionController::API)
16
18
  controllers = bases.flat_map(&:descendants).uniq.select(&:name)
17
19
 
20
+ # { controller => actions to leave out of its draft }. A named controller
21
+ # is drafted even if covered or ignored; the sweep skips both, and leaves
22
+ # out what Permittable.audit_ignore names. Either leaves out an action the
23
+ # controller does not define (see Generator.targets and named_target).
18
24
  if task_args[:controller]
19
25
  controllers = controllers.select { |c| c.name == task_args[:controller] }
20
26
  abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
27
+ targets = controllers.to_h { |c| [c, Permittable::Generator.named_target(c)] }
21
28
  else
22
- controllers = controllers.reject do |c|
23
- c.respond_to?(:permittable_contracts) && c.permittable_contracts.any?
24
- end
29
+ targets = Permittable::Generator.targets(controllers)
25
30
  end
26
31
 
27
- drafted = controllers.sort_by(&:name).count do |controller|
32
+ drafted = targets.sort_by { |controller, _| controller.name }.count do |controller, except|
28
33
  path = begin
29
34
  Object.const_source_location(controller.name)&.first
30
35
  rescue StandardError
31
36
  nil
32
37
  end
33
38
  source = path && File.exist?(path) ? File.read(path) : nil
34
- snippet = Permittable::Generator.for_controller(controller, source: source)
39
+ snippet = Permittable::Generator.for_controller(controller, source: source, except: except)
35
40
  next false unless snippet
36
41
 
37
42
  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.1".freeze
3
3
  end