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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- metadata +2 -2
data/lib/permittable/railtie.rb
CHANGED
|
@@ -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__)
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -96,7 +96,10 @@ module Permittable
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def matching(regexp)
|
|
99
|
-
|
|
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)`
|
|
368
|
-
# Regexp
|
|
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) &&
|
|
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) ||
|
|
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) ||
|
|
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
|
-
|
|
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 =
|
|
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(" ")
|
data/lib/permittable/version.rb
CHANGED