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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +71 -12
- data/lib/permittable/audit.rb +231 -20
- data/lib/permittable/generator.rb +47 -11
- data/lib/permittable/open_api.rb +429 -79
- data/lib/permittable/tasks/generate.rake +9 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +158 -40
- metadata +2 -2
|
@@ -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
|
-
|
|
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 =
|
|
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(" ")
|
data/lib/permittable/version.rb
CHANGED
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
|
|
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
|
|
1900
|
-
#
|
|
1901
|
-
#
|
|
1902
|
-
#
|
|
1903
|
-
#
|
|
1904
|
-
#
|
|
1905
|
-
#
|
|
1906
|
-
#
|
|
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
|
|
1994
|
+
def process(...)
|
|
1911
1995
|
@permittable_wrapper_key = permittable_wrapper_copy_key
|
|
1912
1996
|
super
|
|
1913
1997
|
end
|
|
1914
1998
|
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
#
|
|
1918
|
-
#
|
|
1919
|
-
#
|
|
1920
|
-
#
|
|
1921
|
-
#
|
|
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
|
|
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 =
|
|
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
|
|
2433
|
-
#
|
|
2434
|
-
#
|
|
2435
|
-
#
|
|
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
|
-
#
|
|
2441
|
-
#
|
|
2442
|
-
|
|
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
|
-
|
|
2451
|
-
|
|
2552
|
+
key && permittable_drops_wrapper_copy?(rule, key) ? source.except(key) : source
|
|
2553
|
+
end
|
|
2452
2554
|
|
|
2453
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|