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.rb
CHANGED
|
@@ -136,7 +136,8 @@ require "permittable/filter_parameter_registry"
|
|
|
136
136
|
# declaring `transform:`: its default is stored exactly AS AUTHORED, never
|
|
137
137
|
# cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
|
|
138
138
|
# so author such a default already in the shape the action should receive.
|
|
139
|
-
#
|
|
139
|
+
# An `example:` is cast either way and never transformed: it documents what a
|
|
140
|
+
# client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
|
|
140
141
|
# copy, so no request can corrupt it for the next.
|
|
141
142
|
#
|
|
142
143
|
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
@@ -218,18 +219,29 @@ module Permittable
|
|
|
218
219
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
219
220
|
MODES = %i[enforce monitor].freeze
|
|
220
221
|
ERROR_FORMATS = %i[envelope problem].freeze
|
|
222
|
+
# A Permittable.audit_ignore entry: a controller path the way Rails names
|
|
223
|
+
# it (lowercase, `/`-separated — "active_storage/direct_uploads"),
|
|
224
|
+
# optionally followed by "#action".
|
|
225
|
+
AUDIT_IGNORE_ENTRY = %r{\A[a-z0-9_]+(?:/[a-z0-9_]+)*(?:#\w+)?\z}
|
|
221
226
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
222
227
|
# unknown-keys check must not flag them.
|
|
223
228
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
224
229
|
# Nor the keys an ordinary form POST carries — the CSRF token, the verb
|
|
225
|
-
# override, the encoding probe, and the submit button's name
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
|
|
230
|
+
# override, the encoding probe, and the submit button's name: `commit` from
|
|
231
|
+
# f.submit/submit_tag, `button` from f.button/button_tag (which render
|
|
232
|
+
# <button name="button">, so a browser sends `button=` even with no
|
|
233
|
+
# value). Without this `unknown: :error` was unusable outside a JSON API:
|
|
234
|
+
# every browser form failed on the framework's own keys rather than on
|
|
235
|
+
# anything the client got wrong. Exempt from the CHECK only: unlike the
|
|
236
|
+
# routing keys these are NOT stripped from monitor mode's raw
|
|
237
|
+
# pass-through, where handing back an untouched params hash is the whole
|
|
238
|
+
# promise and a legacy action may well read `_method` itself.
|
|
239
|
+
#
|
|
240
|
+
# The click point of an image submit (image_submit_tag, which has no name
|
|
241
|
+
# of its own to exempt) is a form key too, but not a fixed one: `x` and `y`,
|
|
242
|
+
# or `<name>.x` and `<name>.y`, as a PAIR of digits. See
|
|
243
|
+
# permittable_image_submit_keys.
|
|
244
|
+
FORM_KEYS = %w[authenticity_token _method utf8 commit button].freeze
|
|
233
245
|
# ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
|
|
234
246
|
# is DECIDED with them, which is what the call sites care about — and the
|
|
235
247
|
# asymmetry between them is the deliberate point, so spell it once here
|
|
@@ -238,7 +250,7 @@ module Permittable
|
|
|
238
250
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
239
251
|
# The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
|
|
240
252
|
# rootless contract declaring the wrapper key as one of these reads the
|
|
241
|
-
# copy deliberately, so it is kept (see
|
|
253
|
+
# copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
|
|
242
254
|
WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
|
|
243
255
|
# A log line and an exception message are PROSE, written for a person. They
|
|
244
256
|
# list at most this many names and count the rest, so one request cannot
|
|
@@ -512,6 +524,55 @@ module Permittable
|
|
|
512
524
|
@check_column_types = value
|
|
513
525
|
end
|
|
514
526
|
|
|
527
|
+
# Routed actions that `permittable:audit` leaves out of its counts and of
|
|
528
|
+
# `[strict]`, and that `permittable:generate` does not draft for — the
|
|
529
|
+
# endpoints that are not the app's to guard:
|
|
530
|
+
#
|
|
531
|
+
# Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
|
|
532
|
+
#
|
|
533
|
+
# Each entry is a controller path as Rails routes it (every action of that
|
|
534
|
+
# controller) or "controller#action" (that one action, on every verb — a
|
|
535
|
+
# `via: :all` catch-all is one entry). Ignored actions are still listed,
|
|
536
|
+
# in a section of their own, and an entry that matches no routed action is
|
|
537
|
+
# listed too: an ignore list nothing checks would hide its own typos.
|
|
538
|
+
#
|
|
539
|
+
# Strings only, checked here. A class name is the likeliest mistake and
|
|
540
|
+
# the error names the path meant; a Class is refused because naming one
|
|
541
|
+
# in an initializer autoloads it at boot, which Zeitwerk refuses for a
|
|
542
|
+
# reloadable controller; a Regexp is refused because a pattern would also
|
|
543
|
+
# ignore controllers written AFTER it, and catching a new unguarded
|
|
544
|
+
# endpoint is what the gate is for. Validated as a whole before anything
|
|
545
|
+
# is stored, so a rejected list leaves the previous one in place. nil
|
|
546
|
+
# clears it.
|
|
547
|
+
def audit_ignore
|
|
548
|
+
@audit_ignore || [].freeze
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def audit_ignore=(entries)
|
|
552
|
+
entries = Array(entries).map { |entry| audit_ignore_entry(entry) }
|
|
553
|
+
@audit_ignore = entries.uniq.freeze
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Whether the audit and the generator also leave out every controller
|
|
557
|
+
# whose source is outside Rails.root — engines and gems: ActiveStorage,
|
|
558
|
+
# ActionMailbox, Rails's own rails/* controllers. On by default, since an
|
|
559
|
+
# app cannot add a contract to a gem's controller; those are listed in
|
|
560
|
+
# the report under their own reason rather than dropped. Turn it off to
|
|
561
|
+
# audit them like the app's own:
|
|
562
|
+
#
|
|
563
|
+
# Permittable.audit_ignore_outside_root = false
|
|
564
|
+
#
|
|
565
|
+
# See Audit.outside_root? for how a controller is located.
|
|
566
|
+
def audit_ignore_outside_root
|
|
567
|
+
@audit_ignore_outside_root.nil? || @audit_ignore_outside_root
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def audit_ignore_outside_root=(value)
|
|
571
|
+
raise ArgumentError, "#{LABEL}: audit_ignore_outside_root must be true or false" unless [true, false].include?(value)
|
|
572
|
+
|
|
573
|
+
@audit_ignore_outside_root = value
|
|
574
|
+
end
|
|
575
|
+
|
|
515
576
|
# App-wide fallback copy for a violation code, looked up through I18n
|
|
516
577
|
# under permittable.errors.<code> ("missing", "inclusion", or any Symbol
|
|
517
578
|
# a validate: returned). Consulted only when the field declares no
|
|
@@ -526,6 +587,24 @@ module Permittable
|
|
|
526
587
|
message = ::I18n.t("permittable.errors.#{code}", default: nil)
|
|
527
588
|
message.is_a?(String) ? message : nil
|
|
528
589
|
end
|
|
590
|
+
|
|
591
|
+
private
|
|
592
|
+
|
|
593
|
+
# One audit_ignore entry, frozen, or an ArgumentError that says what an
|
|
594
|
+
# entry looks like — and, for a class or a class name, which path it
|
|
595
|
+
# meant: `ActiveStorage::DirectUploadsController` is the path
|
|
596
|
+
# "active_storage/direct_uploads", the same derivation Rails's own
|
|
597
|
+
# controller_path makes.
|
|
598
|
+
def audit_ignore_entry(entry)
|
|
599
|
+
return entry.dup.freeze if entry.is_a?(String) && AUDIT_IGNORE_ENTRY.match?(entry)
|
|
600
|
+
|
|
601
|
+
meant = entry.controller_path if entry.is_a?(Class) && entry.respond_to?(:controller_path)
|
|
602
|
+
meant ||= entry.underscore.delete_suffix("_controller") if entry.is_a?(String) && entry.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
603
|
+
raise ArgumentError,
|
|
604
|
+
"#{LABEL}: audit_ignore takes controller paths as Rails routes them (\"active_storage/direct_uploads\") " \
|
|
605
|
+
"or \"controller#action\" (\"application#not_found\"), got #{entry.inspect}" \
|
|
606
|
+
"#{" — did you mean #{meant.inspect}?" if meant}"
|
|
607
|
+
end
|
|
529
608
|
end
|
|
530
609
|
|
|
531
610
|
# Raised when the request violates the matching contract. `details` is an
|
|
@@ -545,6 +624,12 @@ module Permittable
|
|
|
545
624
|
class_attribute :permittable_contracts, instance_accessor: false, default: []
|
|
546
625
|
|
|
547
626
|
rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
|
|
627
|
+
# Registered here, at the include, and not at the first `permit_params`:
|
|
628
|
+
# the README documents `skip_before_action :enforce_params_contract`, and
|
|
629
|
+
# Rails raises when it is asked to skip a callback that is not defined
|
|
630
|
+
# yet. The cost is the order: this check runs before any callback
|
|
631
|
+
# declared after the include (see the README's "Per-request locales"),
|
|
632
|
+
# so a locale switch that must cover it is a `prepend_around_action`.
|
|
548
633
|
before_action :enforce_params_contract if respond_to?(:before_action)
|
|
549
634
|
end
|
|
550
635
|
|
|
@@ -808,8 +893,11 @@ module Permittable
|
|
|
808
893
|
source.split(/[eE]/, 2).first.match?(/[1-9]/)
|
|
809
894
|
end
|
|
810
895
|
|
|
896
|
+
# Rational#to_s is "1/2", which BigDecimal() cannot read, so the generic
|
|
897
|
+
# Numeric branch refused every Rational, even a whole one (3r).
|
|
811
898
|
def cast_decimal(value)
|
|
812
899
|
case value
|
|
900
|
+
when Rational then rational_to_decimal(value)
|
|
813
901
|
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
814
902
|
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
815
903
|
else [:error, "invalid_type"]
|
|
@@ -818,6 +906,20 @@ module Permittable
|
|
|
818
906
|
[:error, "invalid_type"]
|
|
819
907
|
end
|
|
820
908
|
|
|
909
|
+
# A Rational is exactly a decimal only when its reduced denominator has
|
|
910
|
+
# no prime factor but 2 and 5 (1/2, 3/40), and then it divides 10**k for
|
|
911
|
+
# k = its bit length, which is at least the exponent of either factor.
|
|
912
|
+
# Anything else (1/3) has no finite expansion, so a BigDecimal could hold
|
|
913
|
+
# it only rounded at a precision this gem would have to pick. That is the
|
|
914
|
+
# guess every other coercion here refuses, so it is invalid_type.
|
|
915
|
+
def rational_to_decimal(value)
|
|
916
|
+
denominator = value.denominator
|
|
917
|
+
scale = denominator.bit_length
|
|
918
|
+
return [:error, "invalid_type"] unless ((10**scale) % denominator).zero?
|
|
919
|
+
|
|
920
|
+
finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
|
|
921
|
+
end
|
|
922
|
+
|
|
821
923
|
# BigDecimal has no exponent limit, so a :decimal cannot overflow — but
|
|
822
924
|
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
823
925
|
# raises, so a client could send the literal string "NaN" for a price and
|
|
@@ -1128,6 +1230,7 @@ module Permittable
|
|
|
1128
1230
|
validate_array_authored_value!(field, :default) if field.key?(:default)
|
|
1129
1231
|
validate_array_authored_value!(field, :example) if field.key?(:example)
|
|
1130
1232
|
validate_message!(field)
|
|
1233
|
+
freeze_desc!(field)
|
|
1131
1234
|
@fields << field
|
|
1132
1235
|
end
|
|
1133
1236
|
|
|
@@ -1204,6 +1307,7 @@ module Permittable
|
|
|
1204
1307
|
fields: nested_fields!(name, &block), **opts }
|
|
1205
1308
|
field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
|
|
1206
1309
|
validate_message!(field)
|
|
1310
|
+
freeze_desc!(field)
|
|
1207
1311
|
elsif type&.to_sym == JSON_TYPE
|
|
1208
1312
|
assert_opts!(name, opts, JSON_OPTS)
|
|
1209
1313
|
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
@@ -1308,6 +1412,7 @@ module Permittable
|
|
|
1308
1412
|
validate_authored_value!(field, :default)
|
|
1309
1413
|
validate_authored_value!(field, :example)
|
|
1310
1414
|
validate_message!(field)
|
|
1415
|
+
freeze_desc!(field)
|
|
1311
1416
|
end
|
|
1312
1417
|
|
|
1313
1418
|
# `in:` is a Range (bounds-checked with cover?), a list of values, or an
|
|
@@ -1323,19 +1428,34 @@ module Permittable
|
|
|
1323
1428
|
# enum rule all read the members the runtime compares against. A member
|
|
1324
1429
|
# no request value could ever equal is a contract mistake, and fails here
|
|
1325
1430
|
# rather than as an `inclusion` on every request.
|
|
1431
|
+
#
|
|
1432
|
+
# An empty list or a reversed Range is refused for the same reason, but
|
|
1433
|
+
# an object used as given is not asked: it is read on every request, so
|
|
1434
|
+
# a live registry that is empty now (filled in an initializer or
|
|
1435
|
+
# `after_initialize`, after `eager_load!` has run this) is the very use
|
|
1436
|
+
# the README recommends it for.
|
|
1326
1437
|
def resolve_in!(field)
|
|
1327
1438
|
name = field[:name]
|
|
1328
1439
|
allowed = field[:in]
|
|
1329
1440
|
if allowed.is_a?(Range)
|
|
1330
1441
|
assert_comparable_range!(field, allowed)
|
|
1442
|
+
assert_satisfiable!(name, :in, allowed)
|
|
1331
1443
|
elsif (members = Coercion.in_list(allowed))
|
|
1332
1444
|
cast_in_members!(field, members)
|
|
1445
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
1333
1446
|
elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
|
|
1334
1447
|
raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
|
|
1335
1448
|
"or a Hash read as its keys), or an object answering include? " \
|
|
1336
1449
|
"(got #{allowed.inspect})#{string_in_hint(allowed)}"
|
|
1337
1450
|
end
|
|
1338
|
-
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
# An `in:` that is neither a Range nor a list the gem read and cast
|
|
1454
|
+
# itself, so one resolve_in! kept exactly as given: its contents are the
|
|
1455
|
+
# host's, and unknown at class load.
|
|
1456
|
+
def live_in?(field)
|
|
1457
|
+
allowed = field[:in]
|
|
1458
|
+
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
1339
1459
|
end
|
|
1340
1460
|
|
|
1341
1461
|
def string_in_hint(allowed)
|
|
@@ -1413,6 +1533,7 @@ module Permittable
|
|
|
1413
1533
|
validate_json_authored_value!(field, :default)
|
|
1414
1534
|
validate_json_authored_value!(field, :example)
|
|
1415
1535
|
validate_message!(field)
|
|
1536
|
+
freeze_desc!(field)
|
|
1416
1537
|
end
|
|
1417
1538
|
|
|
1418
1539
|
def validate_max_depth!(name, depth)
|
|
@@ -1543,7 +1664,7 @@ module Permittable
|
|
|
1543
1664
|
# same value gets, and what used to be thrown away: `default: "18"` used
|
|
1544
1665
|
# to be handed to every request omitting it as the String "18", and
|
|
1545
1666
|
# `:boolean, default: "false"` gave the app a truthy String.
|
|
1546
|
-
# With a `transform:`,
|
|
1667
|
+
# With a `transform:`, a default is stored exactly AS AUTHORED instead —
|
|
1547
1668
|
# `transform:` never runs on a default (see AuthoredValues), so casting it
|
|
1548
1669
|
# here would silently change its type out from under an author who, per
|
|
1549
1670
|
# the README, writes such a default in the shape the action should
|
|
@@ -1551,6 +1672,17 @@ module Permittable
|
|
|
1551
1672
|
# :string field means the app gets the Integer 25 either way, whether the
|
|
1552
1673
|
# request sent "25" (cast then transformed) or omitted the field
|
|
1553
1674
|
# (authored as the already-final Integer).
|
|
1675
|
+
#
|
|
1676
|
+
# An `example:` is cast either way. It documents what a client SENDS and
|
|
1677
|
+
# never reaches the action, so that reason does not apply to it: under
|
|
1678
|
+
# `transform:` it used to be published uncast, `"type": "integer",
|
|
1679
|
+
# "examples": ["18"]`, the very lie the cast exists to stop.
|
|
1680
|
+
#
|
|
1681
|
+
# The one rule not checked is `in:` when it is an object used as given
|
|
1682
|
+
# (see resolve_in!): a live registry is empty at class load, so
|
|
1683
|
+
# `in: LIVE, default: "order.paid"` would be refused for a member it will
|
|
1684
|
+
# hold by the first request. What it will contain is unknown then, so its
|
|
1685
|
+
# `include?` is not asked; every other rule is still checked.
|
|
1554
1686
|
def validate_authored_value!(field, opt)
|
|
1555
1687
|
return unless field.key?(opt)
|
|
1556
1688
|
return if authored_nil!(field, opt)
|
|
@@ -1559,10 +1691,10 @@ module Permittable
|
|
|
1559
1691
|
# proc cannot rewrite the host's own literal.
|
|
1560
1692
|
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1561
1693
|
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1562
|
-
status, result = Coercion.check_scalar(field, value)
|
|
1694
|
+
status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
|
|
1563
1695
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1564
1696
|
|
|
1565
|
-
field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
|
|
1697
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
|
|
1566
1698
|
end
|
|
1567
1699
|
|
|
1568
1700
|
# An array's authored value is validated by the REQUEST walker itself
|
|
@@ -1575,27 +1707,61 @@ module Permittable
|
|
|
1575
1707
|
# cast only the top level of each element, and got every one of those
|
|
1576
1708
|
# wrong.
|
|
1577
1709
|
#
|
|
1578
|
-
# What is STORED follows the same split as a scalar's:
|
|
1579
|
-
# `transform:`, the walker's read (exactly what a request sending
|
|
1580
|
-
# gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1581
|
-
# so a declaration mistake (an element `validate:` refuses, a
|
|
1582
|
-
# default out of bounds) still fails at class load, but its
|
|
1583
|
-
# is discarded rather than stored. The array's OWN
|
|
1584
|
-
# deliberately never run on a default either way
|
|
1585
|
-
# `transform:` still runs during that walk, so "the walker's
|
|
1586
|
-
# really is what an equivalent request produces
|
|
1710
|
+
# What is STORED for a default follows the same split as a scalar's:
|
|
1711
|
+
# without `transform:`, the walker's read (exactly what a request sending
|
|
1712
|
+
# it gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1713
|
+
# runs, so a declaration mistake (an element `validate:` refuses, a
|
|
1714
|
+
# sub-field default out of bounds) still fails at class load, but its
|
|
1715
|
+
# cast result is discarded rather than stored. The array's OWN
|
|
1716
|
+
# `transform:` is deliberately never run on a default either way. A
|
|
1717
|
+
# SUB-FIELD's `transform:` still runs during that walk, so "the walker's
|
|
1718
|
+
# read" above really is what an equivalent request produces: all of them
|
|
1719
|
+
# when the array has no `transform:` of its own, and, when it has one,
|
|
1720
|
+
# those below an array declaring `validate:`, the only readers of their
|
|
1721
|
+
# output (a request runs sub-field transforms, then that `validate:`,
|
|
1722
|
+
# then the array's own transform) — see AuthoredValues.
|
|
1723
|
+
#
|
|
1724
|
+
# An `example:` is validated the same way, as a request would be, but what
|
|
1725
|
+
# is STORED is a second read of it exactly as a client SENDS it: cast and
|
|
1726
|
+
# normalized, with no `transform:` run at any depth and no sub-field
|
|
1727
|
+
# `default:` filled in. Read through a sub-field's transform,
|
|
1728
|
+
# `[{ price: "9.99" }]` under `transform: ->(d) { (d * 100).to_i }` was
|
|
1729
|
+
# published as `[{ "price": 999 }]`: 100 times the price, and a `tags`
|
|
1730
|
+
# split into an Array the server refuses. The two reads cannot be one: an
|
|
1731
|
+
# array-level `validate:` must see the transformed sub-fields a request
|
|
1732
|
+
# hands it (`i[:tags].is_a?(Array)`), so the validating read keeps them,
|
|
1733
|
+
# and the stored read, which runs no `validate:`, discards them.
|
|
1734
|
+
#
|
|
1735
|
+
# The transforming read exists only for that `validate:`, so it runs only
|
|
1736
|
+
# when one could consume it. Anywhere else its output is thrown away, yet
|
|
1737
|
+
# it would still call app code at class load: `optional :id, :string,
|
|
1738
|
+
# transform: ->(s) { Account.find(s) }` under an `example:` hit the
|
|
1739
|
+
# database, or raised, at boot. Only an array carries a `validate:` that
|
|
1740
|
+
# sees its transformed children: a scalar's or a :json field's runs on
|
|
1741
|
+
# its own value before its own transform, and a nested hash takes none.
|
|
1742
|
+
# Without one, the as-sent read both validates and is stored.
|
|
1587
1743
|
def validate_array_authored_value!(field, opt)
|
|
1588
1744
|
value = field[opt]
|
|
1589
1745
|
return if authored_nil!(field, opt)
|
|
1590
1746
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
1591
1747
|
|
|
1592
|
-
|
|
1748
|
+
as_sent = opt == :example && !array_validate_in?(field)
|
|
1749
|
+
read, violations = AuthoredValues.read_array(field, value, as_sent: as_sent)
|
|
1593
1750
|
unless violations.empty?
|
|
1594
1751
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
|
|
1595
1752
|
"#{AuthoredValues.summary(violations)}"
|
|
1596
1753
|
end
|
|
1597
1754
|
|
|
1598
|
-
|
|
1755
|
+
read, = AuthoredValues.read_array(field, value, as_sent: true) if opt == :example && !as_sent
|
|
1756
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? value : read)
|
|
1757
|
+
end
|
|
1758
|
+
|
|
1759
|
+
# Whether `field`, or any array or nested hash below it, declares an
|
|
1760
|
+
# array-level `validate:`.
|
|
1761
|
+
def array_validate_in?(field)
|
|
1762
|
+
return true if field[:kind] == :array && field[:validate]
|
|
1763
|
+
|
|
1764
|
+
Array(field[:fields]).any? { |sub| array_validate_in?(sub) }
|
|
1599
1765
|
end
|
|
1600
1766
|
|
|
1601
1767
|
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
@@ -1634,6 +1800,18 @@ module Permittable
|
|
|
1634
1800
|
"declare nullable: true to make an explicit null part of the contract"
|
|
1635
1801
|
end
|
|
1636
1802
|
|
|
1803
|
+
# `desc:` is exported as the schema's `description`, and was handed out
|
|
1804
|
+
# by reference: a caller appending to a generated document's
|
|
1805
|
+
# description (" (deprecated)", a gsub! for localisation) rewrote the
|
|
1806
|
+
# contract inside the frozen rule, and every later export in the process
|
|
1807
|
+
# carried the edit. Frozen as a copy, like message:, default: and
|
|
1808
|
+
# example:, so the host's own String is left as it passed it. The
|
|
1809
|
+
# exporters hand out an unfrozen `dup` of it, so editing a generated
|
|
1810
|
+
# document in place works and still cannot reach the contract.
|
|
1811
|
+
def freeze_desc!(field)
|
|
1812
|
+
field[:desc] = freeze_authored(field[:desc]) unless field[:desc].nil?
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1637
1815
|
# `message:` customizes what the client reads for a violation on this
|
|
1638
1816
|
# field: one String covering every code, or a Hash of code => String
|
|
1639
1817
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -1733,7 +1911,7 @@ module Permittable
|
|
|
1733
1911
|
|
|
1734
1912
|
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
1735
1913
|
model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
|
|
1736
|
-
finalize: builder.finalizer, desc: desc }.freeze
|
|
1914
|
+
finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
|
|
1737
1915
|
self.permittable_contracts = permittable_contracts + [rule]
|
|
1738
1916
|
end
|
|
1739
1917
|
|
|
@@ -1780,10 +1958,11 @@ module Permittable
|
|
|
1780
1958
|
|
|
1781
1959
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
1782
1960
|
allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
|
|
1961
|
+
transformed = checked.select { |f| f[:transform] }.map { |f| f[:name].to_sym }
|
|
1783
1962
|
begin
|
|
1784
1963
|
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1785
1964
|
types: types, check_types: Permittable.check_column_types,
|
|
1786
|
-
allowed: allowed)
|
|
1965
|
+
allowed: allowed, transformed: transformed)
|
|
1787
1966
|
rescue ArgumentError => e
|
|
1788
1967
|
# The type error carries its own guidance; only the missing-column one
|
|
1789
1968
|
# needs the virtual: hint appended.
|
|
@@ -1858,10 +2037,33 @@ module Permittable
|
|
|
1858
2037
|
return nil unless action
|
|
1859
2038
|
|
|
1860
2039
|
rule = self.class.permit_rule_for(action)
|
|
1861
|
-
|
|
2040
|
+
return nil unless rule
|
|
2041
|
+
|
|
2042
|
+
if permittable_mode(rule) == :monitor
|
|
2043
|
+
permittable_monitor_eagerly(action)
|
|
2044
|
+
elsif rule[:enforce]
|
|
2045
|
+
permitted_params(action)
|
|
2046
|
+
end
|
|
1862
2047
|
nil
|
|
1863
2048
|
end
|
|
1864
2049
|
|
|
2050
|
+
# Monitor mode's promise is that the action runs, whatever the request
|
|
2051
|
+
# holds — that is what makes it safe to turn on. Checking eagerly reads
|
|
2052
|
+
# `params`, which raises ActionDispatch's ParseError on a body Rails
|
|
2053
|
+
# cannot parse: a webhook action reading request.raw_post itself got a
|
|
2054
|
+
# 400 instead of running. An exception from the contract's own code
|
|
2055
|
+
# (validate:, transform:, finalize) became a 500 the same way. Either is
|
|
2056
|
+
# now logged, by class only (a parse error's message can quote the body),
|
|
2057
|
+
# and the action runs; one that reads permitted_params meets the same
|
|
2058
|
+
# exception itself, as it would with no contract at all.
|
|
2059
|
+
def permittable_monitor_eagerly(action)
|
|
2060
|
+
permitted_params(action)
|
|
2061
|
+
rescue StandardError => e
|
|
2062
|
+
return unless respond_to?(:logger) && logger
|
|
2063
|
+
|
|
2064
|
+
logger.warn("#{LABEL}: [monitor] ##{action} could not be checked: #{e.class} — the action runs unchecked")
|
|
2065
|
+
end
|
|
2066
|
+
|
|
1865
2067
|
# The violation details recorded by validating `action` (default: the
|
|
1866
2068
|
# current action) — [] when the request satisfied the contract. Triggers
|
|
1867
2069
|
# the same memoized validation as permitted_params, so under monitor mode
|
|
@@ -1891,38 +2093,74 @@ module Permittable
|
|
|
1891
2093
|
)
|
|
1892
2094
|
end
|
|
1893
2095
|
|
|
1894
|
-
private
|
|
1895
|
-
|
|
1896
2096
|
# ParamsWrapper copies a JSON body under the controller's wrapper key
|
|
1897
2097
|
# (`user` for UsersController) — on by default in a Rails app — and a
|
|
1898
2098
|
# 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
|
-
#
|
|
2099
|
+
# well-formed request. Whether the copy is Rails' own has to be read before
|
|
2100
|
+
# ParamsWrapper#process_action wraps: afterwards `_wrapper_enabled?` answers
|
|
2101
|
+
# false, because params now carry the key, and nothing left in the request
|
|
2102
|
+
# tells Rails' copy from a client that sent `user` itself, which is exactly
|
|
2103
|
+
# the key the check must still flag. (Comparing the key's value with what
|
|
2104
|
+
# the wrapper would have built only guesses: a client can send that value.)
|
|
2105
|
+
#
|
|
2106
|
+
# So it is read here, in `process`, not in process_action. Every
|
|
2107
|
+
# process_action, ParamsWrapper's included, runs inside
|
|
2108
|
+
# AbstractController::Base#process, so no module order can put the
|
|
2109
|
+
# wrapping first. A process_action hook relied on Rails' own order:
|
|
2110
|
+
# ActionController::Base includes ParamsWrapper, so an
|
|
2111
|
+
# ApplicationController's Permittable runs before it. A Metal controller
|
|
2112
|
+
# that includes ParamsWrapper AFTER Permittable (in a subclass, say) ran
|
|
2113
|
+
# ParamsWrapper's process_action first, and every wrapped JSON request was
|
|
2114
|
+
# a 422 again.
|
|
2115
|
+
#
|
|
2116
|
+
# Public, like the method it wraps, so a caller that sends `process`
|
|
2117
|
+
# explicitly still can. It never becomes an action:
|
|
2118
|
+
# AbstractController::Base#process is a public method of every abstract
|
|
2119
|
+
# controller class (Metal, Base, API), and action_methods leaves those out
|
|
2120
|
+
# as internal. On a plain duck, which has no ParamsWrapper, the guards
|
|
2121
|
+
# make it a pass-through to whatever `process` the host has.
|
|
1907
2122
|
# Assigned on every request, never only when true: a controller instance
|
|
1908
2123
|
# dispatched twice would otherwise carry one request's exemption into the
|
|
1909
2124
|
# next, where a `user` the client did send would pass as Rails' copy.
|
|
1910
|
-
def
|
|
2125
|
+
def process(...)
|
|
1911
2126
|
@permittable_wrapper_key = permittable_wrapper_copy_key
|
|
1912
2127
|
super
|
|
1913
2128
|
end
|
|
1914
2129
|
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
#
|
|
1918
|
-
#
|
|
1919
|
-
#
|
|
1920
|
-
#
|
|
1921
|
-
#
|
|
2130
|
+
private
|
|
2131
|
+
|
|
2132
|
+
# The wrapper key, if ParamsWrapper is about to copy the body under it and
|
|
2133
|
+
# a contract on this controller would act on that; nil otherwise.
|
|
2134
|
+
#
|
|
2135
|
+
# The request is asked only when the answer can matter. A controller none
|
|
2136
|
+
# of whose contracts reads its input without the copy (see
|
|
2137
|
+
# permittable_drops_wrapper_copy?) — only rooted contracts, say, or the
|
|
2138
|
+
# default `unknown: :ignore` with no field of the wrapper's name — paid a
|
|
2139
|
+
# content-type lookup and a params read on every request for nothing.
|
|
2140
|
+
# Everything the gate reads is class-level (the wrapper's formats and name,
|
|
2141
|
+
# and the contracts), so a skipped probe touches nothing of the request.
|
|
2142
|
+
# The formats come first, in ParamsWrapper's own order: with none it never
|
|
2143
|
+
# wraps and never asks for its name — which it could not give, an
|
|
2144
|
+
# unconfigured wrapper having no controller to derive one from. The
|
|
2145
|
+
# contract gate is ALL of the controller's contracts, not only the one
|
|
2146
|
+
# covering this action: permitted_params(:create) can read another
|
|
2147
|
+
# action's contract from inside #update, and by then ParamsWrapper has
|
|
2148
|
+
# wrapped and the question can no longer be answered.
|
|
2149
|
+
#
|
|
2150
|
+
# `_wrapper_enabled?` asks the params for the key as configured. Rails'
|
|
2151
|
+
# params are indifferent on every supported version, so that already
|
|
2152
|
+
# covers `wrap_parameters :user` and "user" alike. Asking again for the
|
|
2153
|
+
# String keeps a client's `user` the client's on a plain string-keyed
|
|
2154
|
+
# params Hash too — one a test seeds into the request directly — where
|
|
2155
|
+
# :user would answer "not sent" and ParamsWrapper would wrap anyway.
|
|
1922
2156
|
def permittable_wrapper_copy_key
|
|
1923
|
-
return unless
|
|
2157
|
+
return unless %i[_wrapper_enabled? _wrapper_formats _wrapper_key].all? { |name| respond_to?(name, true) }
|
|
2158
|
+
return if _wrapper_formats.empty?
|
|
2159
|
+
|
|
2160
|
+
key = _wrapper_key&.to_s
|
|
2161
|
+
return unless key && self.class.permittable_contracts.any? { |rule| permittable_drops_wrapper_copy?(rule, key) }
|
|
2162
|
+
return unless _wrapper_enabled?
|
|
1924
2163
|
|
|
1925
|
-
key = _wrapper_key.to_s
|
|
1926
2164
|
key unless request.parameters.key?(key)
|
|
1927
2165
|
end
|
|
1928
2166
|
|
|
@@ -1944,7 +2182,7 @@ module Permittable
|
|
|
1944
2182
|
source = permittable_root_hash(rule, violations)
|
|
1945
2183
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
1946
2184
|
if source
|
|
1947
|
-
checked =
|
|
2185
|
+
checked = permittable_without_wrapper_copy(rule, source)
|
|
1948
2186
|
result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
|
|
1949
2187
|
unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
|
|
1950
2188
|
end
|
|
@@ -2223,6 +2461,7 @@ module Permittable
|
|
|
2223
2461
|
violations << permittable_violation(field, full, "invalid_type")
|
|
2224
2462
|
end
|
|
2225
2463
|
when :array
|
|
2464
|
+
value = value.values if permittable_fields_for_records?(field, value)
|
|
2226
2465
|
if value.is_a?(Array)
|
|
2227
2466
|
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
2228
2467
|
else
|
|
@@ -2231,6 +2470,20 @@ module Permittable
|
|
|
2231
2470
|
end
|
|
2232
2471
|
end
|
|
2233
2472
|
|
|
2473
|
+
# Whether `value` is an array of hashes sent the way Rails' fields_for
|
|
2474
|
+
# sends one: `user[addresses_attributes][0][city]` reaches the params as a
|
|
2475
|
+
# Hash keyed "0", "1", ... (or a timestamp, for a record added in the
|
|
2476
|
+
# browser). Strong params permits exactly this shape (its
|
|
2477
|
+
# fields_for_style?: every key an integer, every value a hash) and
|
|
2478
|
+
# accepts_nested_attributes_for reads its values in order, so an
|
|
2479
|
+
# `array ... do` field reads them as its elements. Paths then count the
|
|
2480
|
+
# records in the order sent. An empty Hash, or one of scalars, is not this
|
|
2481
|
+
# shape and stays invalid_type.
|
|
2482
|
+
def permittable_fields_for_records?(field, value)
|
|
2483
|
+
field[:fields] && value.is_a?(Hash) && !value.empty? &&
|
|
2484
|
+
value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
|
|
2485
|
+
end
|
|
2486
|
+
|
|
2234
2487
|
# Coercion.check_json, with the copy the result needs made in the middle.
|
|
2235
2488
|
# The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2236
2489
|
# rebuilt its containers but not the Strings inside them, so it is
|
|
@@ -2293,8 +2546,9 @@ module Permittable
|
|
|
2293
2546
|
# NOTHING violated, validate: included — a transform may rely on what
|
|
2294
2547
|
# validate: checked (`Math.sqrt` after "all positive").
|
|
2295
2548
|
elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
|
|
2296
|
-
|
|
2297
|
-
|
|
2549
|
+
validator = permittable_array_validator(field)
|
|
2550
|
+
if validator && elements_valid
|
|
2551
|
+
status, code = Coercion.check_custom(validator, out)
|
|
2298
2552
|
violations << permittable_violation(field, path, code) unless status == :ok
|
|
2299
2553
|
end
|
|
2300
2554
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
@@ -2309,6 +2563,11 @@ module Permittable
|
|
|
2309
2563
|
field[:transform] ? field[:transform].call(value) : value
|
|
2310
2564
|
end
|
|
2311
2565
|
|
|
2566
|
+
# A second seam, for the same reader: the array-level `validate:` to run.
|
|
2567
|
+
def permittable_array_validator(field)
|
|
2568
|
+
field[:validate]
|
|
2569
|
+
end
|
|
2570
|
+
|
|
2312
2571
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
2313
2572
|
if field[:fields]
|
|
2314
2573
|
unless element.is_a?(Hash)
|
|
@@ -2385,6 +2644,7 @@ module Permittable
|
|
|
2385
2644
|
if top_level
|
|
2386
2645
|
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2387
2646
|
extra -= [permittable_configured_csrf_key].compact
|
|
2647
|
+
extra -= permittable_image_submit_keys(hash, extra)
|
|
2388
2648
|
end
|
|
2389
2649
|
return if extra.empty?
|
|
2390
2650
|
|
|
@@ -2410,6 +2670,33 @@ module Permittable
|
|
|
2410
2670
|
request.path_parameters.keys.map(&:to_s)
|
|
2411
2671
|
end
|
|
2412
2672
|
|
|
2673
|
+
# The click point of an image submit among `keys` (the undeclared top-level
|
|
2674
|
+
# keys). image_submit_tag renders <input type="image">, and a browser
|
|
2675
|
+
# submitting through it posts where it was clicked: `x=12&y=7`, or
|
|
2676
|
+
# `save.x=12&save.y=7` when the tag is named "save" (Rack keeps a dotted
|
|
2677
|
+
# key as it is). Like the other FORM_KEYS these are the framework's own
|
|
2678
|
+
# keys, so `unknown: :error` must not fail the form for them. Unlike them
|
|
2679
|
+
# `x` and `y` are ordinary names a contract may really be sent, so a
|
|
2680
|
+
# lone `x` is still flagged: a point is exempt only as a PAIR, both of
|
|
2681
|
+
# them in `keys` (a declared `x` or `y` is not, and is checked as a field)
|
|
2682
|
+
# and both digits, which is all a browser sends (coordinates are never
|
|
2683
|
+
# negative or fractional). Read by bytes, since a client's key or value can
|
|
2684
|
+
# be invalid UTF-8, which a regexp refuses to match, or another encoding
|
|
2685
|
+
# altogether (a UTF-16 key makes `end_with?` raise).
|
|
2686
|
+
def permittable_image_submit_keys(hash, keys)
|
|
2687
|
+
by_bytes = keys.to_h { |key| [key.b, key] }
|
|
2688
|
+
by_bytes.each_with_object([]) do |(bytes, key), point|
|
|
2689
|
+
next unless bytes == "x" || bytes.end_with?(".x")
|
|
2690
|
+
|
|
2691
|
+
partner = by_bytes["#{bytes.delete_suffix('x')}y"]
|
|
2692
|
+
point.push(key, partner) if partner && [key, partner].all? { |name| permittable_digits?(hash[name]) }
|
|
2693
|
+
end
|
|
2694
|
+
end
|
|
2695
|
+
|
|
2696
|
+
def permittable_digits?(value)
|
|
2697
|
+
value.is_a?(String) && !value.empty? && value.each_byte.all? { |byte| byte.between?(48, 57) }
|
|
2698
|
+
end
|
|
2699
|
+
|
|
2413
2700
|
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2414
2701
|
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2415
2702
|
# lets an app rename it, and an app that does trips `unknown: :error` on
|
|
@@ -2429,28 +2716,39 @@ module Permittable
|
|
|
2429
2716
|
token && token.to_s
|
|
2430
2717
|
end
|
|
2431
2718
|
|
|
2432
|
-
# A
|
|
2433
|
-
#
|
|
2434
|
-
#
|
|
2435
|
-
#
|
|
2719
|
+
# A contract's input without ParamsWrapper's copy of the body, when Rails
|
|
2720
|
+
# made one (see process) and the contract reads its input without it (see
|
|
2721
|
+
# permittable_drops_wrapper_copy?). Removed rather than merely exempted
|
|
2722
|
+
# from the unknown-keys check, because the client never sent that key: a
|
|
2723
|
+
# contract that happens to declare a scalar or array field of the
|
|
2436
2724
|
# wrapper's name (`optional :feedback, :string` on FeedbackController)
|
|
2437
2725
|
# 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)
|
|
2726
|
+
# false 422 invalid_type for a well-formed request. Top level only, where
|
|
2727
|
+
# the copy lives. What is checked changes, not what monitor mode hands
|
|
2728
|
+
# back: its raw pass-through still carries the copy, as the pre-contract
|
|
2729
|
+
# app's params did.
|
|
2730
|
+
def permittable_without_wrapper_copy(rule, source)
|
|
2449
2731
|
key = @permittable_wrapper_key
|
|
2450
|
-
|
|
2451
|
-
|
|
2732
|
+
key && permittable_drops_wrapper_copy?(rule, key) ? source.except(key) : source
|
|
2733
|
+
end
|
|
2452
2734
|
|
|
2453
|
-
|
|
2735
|
+
# Whether `rule` reads its input without ParamsWrapper's copy under `key`
|
|
2736
|
+
# — and so whether the probe's answer changes anything for it, which is
|
|
2737
|
+
# why permittable_wrapper_copy_key asks this too before touching the
|
|
2738
|
+
# request. Not for a rooted contract: it reads the copy as its root, which
|
|
2739
|
+
# is exactly what ParamsWrapper is for. Not for a rootless one declaring
|
|
2740
|
+
# `key` as a hash container (a nested block or :json) either: that
|
|
2741
|
+
# contract is reading the copy ON PURPOSE, a root: spelled as a field, and
|
|
2742
|
+
# it worked that way before the copy was ever dropped — dropping it would
|
|
2743
|
+
# turn every such request into `user missing`. Any other field of that
|
|
2744
|
+
# name would validate the copy, so the copy goes whatever the unknown:
|
|
2745
|
+
# mode. Undeclared, it would only be flagged as unknown, which
|
|
2746
|
+
# `unknown: :ignore` never does, so there the copy changes nothing.
|
|
2747
|
+
def permittable_drops_wrapper_copy?(rule, key)
|
|
2748
|
+
return false if rule[:root]
|
|
2749
|
+
|
|
2750
|
+
field = rule[:fields].find { |f| f[:name].to_s == key }
|
|
2751
|
+
field ? !WRAPPER_CONTAINER_KINDS.include?(field[:kind]) : rule[:unknown] != :ignore
|
|
2454
2752
|
end
|
|
2455
2753
|
|
|
2456
2754
|
def permittable_path(path, key)
|