permittable 0.13.1 → 0.15.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 +43 -0
- data/README.md +34 -11
- data/lib/permittable/audit.rb +64 -7
- data/lib/permittable/authored_values.rb +23 -11
- data/lib/permittable/column_guard.rb +133 -12
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +9 -3
- data/lib/permittable/open_api.rb +213 -11
- data/lib/permittable/rspec.rb +193 -12
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +349 -24
- metadata +2 -2
data/lib/permittable/rspec.rb
CHANGED
|
@@ -453,7 +453,11 @@ module Permittable
|
|
|
453
453
|
# proc cannot rewrite the spec's own literal (the same reason
|
|
454
454
|
# validate_authored_value! copies before normalizing).
|
|
455
455
|
own = expected.is_a?(String) ? expected.dup : expected
|
|
456
|
-
|
|
456
|
+
normalized = Coercion.apply_normalize(field[:normalize], own)
|
|
457
|
+
# Absent on a nullable field is the nil a contract stores for it.
|
|
458
|
+
return nil if field[:nullable] && Coercion.absent_value?(normalized)
|
|
459
|
+
|
|
460
|
+
status, value = Coercion.cast(field[:type], normalized)
|
|
457
461
|
status == :ok ? value : expected
|
|
458
462
|
when :array
|
|
459
463
|
return expected unless expected.is_a?(Array)
|
|
@@ -465,6 +469,8 @@ module Permittable
|
|
|
465
469
|
end
|
|
466
470
|
|
|
467
471
|
def cast_json_default(field, expected)
|
|
472
|
+
# Absent on a nullable field is the nil a contract stores for it.
|
|
473
|
+
return nil if field[:nullable] && Coercion.absent_value?(expected)
|
|
468
474
|
return expected unless expected.is_a?(Hash)
|
|
469
475
|
|
|
470
476
|
status, value = AuthoredValues.read_json(field, expected)
|
|
@@ -630,7 +636,7 @@ module Permittable
|
|
|
630
636
|
# `not_to ...returning(x)` would pass both on a rejection and on an
|
|
631
637
|
# acceptance returning anything else.
|
|
632
638
|
def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
|
|
633
|
-
raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
|
|
639
|
+
raise ArgumentError, negated_qualifier_message(subject) if @returning || !@expected_violations.empty?
|
|
634
640
|
|
|
635
641
|
rule = locate(subject)
|
|
636
642
|
return false unless rule
|
|
@@ -645,9 +651,12 @@ module Permittable
|
|
|
645
651
|
if @expect_accepted
|
|
646
652
|
return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
|
|
647
653
|
|
|
648
|
-
"expected #{subject_name} to #{description}, but it accepted them but returned
|
|
654
|
+
"expected #{subject_name} to #{description}, but it accepted them but returned " \
|
|
655
|
+
"#{printable(@result.to_h)}#{differs_note}"
|
|
649
656
|
else
|
|
650
|
-
|
|
657
|
+
if @violations.empty?
|
|
658
|
+
return "expected #{subject_name} to #{description}, but it accepted them, returning #{printable(@result.to_h)}"
|
|
659
|
+
end
|
|
651
660
|
|
|
652
661
|
"expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
|
|
653
662
|
end
|
|
@@ -664,7 +673,7 @@ module Permittable
|
|
|
664
673
|
label = @expect_accepted ? "accept those params" : "reject those params"
|
|
665
674
|
label += action_label
|
|
666
675
|
label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
667
|
-
label += " returning #{@returning
|
|
676
|
+
label += " returning #{printable(@returning)}" if @returning
|
|
668
677
|
label
|
|
669
678
|
end
|
|
670
679
|
|
|
@@ -678,15 +687,27 @@ module Permittable
|
|
|
678
687
|
# first so a matcher object reused across examples (or re-chained
|
|
679
688
|
# with another for_action) never reports a previous run's problem.
|
|
680
689
|
def locate(subject)
|
|
681
|
-
@problem = @violations = @result = nil
|
|
690
|
+
@problem = @violations = @result = @root = nil
|
|
682
691
|
@subject = resolve_subject(subject)
|
|
683
|
-
resolve_rule
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
692
|
+
rule = resolve_rule
|
|
693
|
+
@root = rule[:root] if rule
|
|
694
|
+
rule
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def negated_qualifier_message(subject = nil)
|
|
698
|
+
# Raised before the rule is located, so the root a scoped filter
|
|
699
|
+
# needs is looked up here, best effort: a subject or an ambiguity
|
|
700
|
+
# that locate would refuse (a BasicObject answers no respond_to?) is not
|
|
701
|
+
# this message's to report, and leaves the filter unscoped (the
|
|
702
|
+
# shallow one) rather than masking the qualifier error.
|
|
703
|
+
begin
|
|
704
|
+
locate(subject) if subject
|
|
705
|
+
rescue StandardError
|
|
706
|
+
@root = nil
|
|
707
|
+
end
|
|
687
708
|
call = @expect_accepted ? "accept_params" : "reject_params"
|
|
688
709
|
qualifiers = []
|
|
689
|
-
qualifiers << "returning #{@returning
|
|
710
|
+
qualifiers << "returning #{printable(@returning)}" if @returning
|
|
690
711
|
qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
691
712
|
"#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
|
|
692
713
|
"negating one is ambiguous, since it would pass both when the params are " \
|
|
@@ -749,7 +770,7 @@ module Permittable
|
|
|
749
770
|
mirror_csrf_key(host)
|
|
750
771
|
host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
|
|
751
772
|
instance = host.new
|
|
752
|
-
instance.params =
|
|
773
|
+
instance.params = request_params
|
|
753
774
|
violations = instance.permittable_violations(action)
|
|
754
775
|
result = violations.empty? ? instance.permitted_params(action) : nil
|
|
755
776
|
[violations, result]
|
|
@@ -772,6 +793,56 @@ module Permittable
|
|
|
772
793
|
host.define_method(:permittable_action_name) { action }
|
|
773
794
|
end
|
|
774
795
|
|
|
796
|
+
# What a controller reads as `params`: the payload after the
|
|
797
|
+
# normalization every request goes through, ActionDispatch's Utils.
|
|
798
|
+
# Under the default perform_deep_munge it removes nil
|
|
799
|
+
# from every array, so a JSON `{"tags": ["a", null]}` reaches the
|
|
800
|
+
# contract as ["a"], and `length:` never counts the null. Handing the
|
|
801
|
+
# raw payload over made reject_params(tags: ["a", nil]) pass for a
|
|
802
|
+
# request that is a 200. An app that turned perform_deep_munge off
|
|
803
|
+
# still sees the null, because the setting is read, not assumed.
|
|
804
|
+
# A copy goes in: the normalizer rewrites arrays in place, and the
|
|
805
|
+
# payload belongs to the spec. The copy stringifies Symbol keys, which
|
|
806
|
+
# the normalizer's HashWithIndifferentAccess does anyway but only after
|
|
807
|
+
# it has read a Symbol :tempfile key as an upload, a key a request
|
|
808
|
+
# (String keys only) never has, and Symbol values (see copy_payload).
|
|
809
|
+
# A Parameters payload is unwrapped first so it is normalized like the
|
|
810
|
+
# Hash it wraps. Without actionpack's request code only the normalizer
|
|
811
|
+
# is skipped; the copy (and its Symbol stringifying) still happens.
|
|
812
|
+
def request_params
|
|
813
|
+
payload = copy_payload(@params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params)
|
|
814
|
+
utils = request_utils
|
|
815
|
+
utils ? utils.normalize_encode_params(payload) : payload
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Rails main moved the class from ActionDispatch::Request::Utils to
|
|
819
|
+
# ActionDispatch::Http::Utils. nil when actionpack's request code is not
|
|
820
|
+
# loaded (a non-Rails stand-in controller, or a bare ActionDispatch module
|
|
821
|
+
# that something else namespaced): there is nothing to normalize with, and
|
|
822
|
+
# nothing was renamed. Once ActionDispatch::Request exists, a rename this
|
|
823
|
+
# does not know raises instead of silently skipping the normalization,
|
|
824
|
+
# which would bring the false greens back.
|
|
825
|
+
def request_utils
|
|
826
|
+
if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
|
|
827
|
+
elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
|
|
828
|
+
elsif defined?(ActionDispatch::Request)
|
|
829
|
+
raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
|
|
830
|
+
"ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# Symbol keys become Strings, and so do Symbol values: a request carries
|
|
835
|
+
# every scalar as a String (or a JSON number, boolean or null), never a
|
|
836
|
+
# Symbol, so `status: :active` is the "active" the contract would read.
|
|
837
|
+
def copy_payload(value)
|
|
838
|
+
case value
|
|
839
|
+
when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
|
|
840
|
+
when Array then value.map { |item| copy_payload(item) }
|
|
841
|
+
when Symbol then value.to_s
|
|
842
|
+
else value
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
|
|
775
846
|
def run_contract
|
|
776
847
|
result = @subject.call(@params)
|
|
777
848
|
[result.violations, result.params]
|
|
@@ -824,6 +895,116 @@ module Permittable
|
|
|
824
895
|
raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
|
|
825
896
|
end
|
|
826
897
|
|
|
898
|
+
# A hash echoed into a failure message, which lands in CI logs, goes out
|
|
899
|
+
# redacted the way a request log is: by the names `sensitive:` fields
|
|
900
|
+
# registered, and by the app's config.filter_parameters when a Rails
|
|
901
|
+
# application is booted. Only the printed copy is filtered; the matching
|
|
902
|
+
# itself reads the real values.
|
|
903
|
+
def printable(hash)
|
|
904
|
+
redacted(hash).inspect
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# The redacted structure itself, which differs_note walks beside the
|
|
908
|
+
# real one. A rule with a root: has its hash put back under that root
|
|
909
|
+
# for the app's filter, as a request logs it, so a scoped filter
|
|
910
|
+
# ("payment.number", /\Apayment\.code\z/) matches; the registry pass
|
|
911
|
+
# runs on the hash alone, as it does at runtime.
|
|
912
|
+
def redacted(hash)
|
|
913
|
+
inner = redact_sensitive(hash)
|
|
914
|
+
filter = parameter_filter
|
|
915
|
+
return inner unless filter
|
|
916
|
+
return filter.filter(inner) unless @root
|
|
917
|
+
|
|
918
|
+
key = @root.to_s
|
|
919
|
+
filter.filter(key => inner)[key]
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# The registry's names, by whatever it can answer: #include?, else its
|
|
923
|
+
# #names matched the way the registry matches (case-insensitive
|
|
924
|
+
# substring). One with neither is left to its #to_proc in
|
|
925
|
+
# parameter_filter. A masked value is replaced whole, a Hash included.
|
|
926
|
+
# An ActionController::Parameters is unwrapped, so its nested values
|
|
927
|
+
# are reached too.
|
|
928
|
+
def redact_sensitive(value)
|
|
929
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
930
|
+
return value.map { |item| redact_sensitive(item) } if value.is_a?(Array)
|
|
931
|
+
return value unless value.is_a?(Hash)
|
|
932
|
+
|
|
933
|
+
registry = Permittable.filter_parameter_registry
|
|
934
|
+
value.to_h do |key, item|
|
|
935
|
+
[key, sensitive_name?(registry, key) ? Permittable::FilterParameterRegistry::FILTERED : redact_sensitive(item)]
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def sensitive_name?(registry, key)
|
|
940
|
+
if registry.respond_to?(:include?)
|
|
941
|
+
registry.include?(key)
|
|
942
|
+
elsif registry.respond_to?(:names)
|
|
943
|
+
name = key.to_s.downcase
|
|
944
|
+
registry.names.any? { |sensitive| name.include?(sensitive.to_s.downcase) }
|
|
945
|
+
else
|
|
946
|
+
false
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Rails' ParameterFilter over the app's config.filter_parameters, plus
|
|
951
|
+
# the registry's own #to_proc when it can answer neither #include? nor
|
|
952
|
+
# #names (a proc masks only Strings, but it is all such a registry
|
|
953
|
+
# offers). ActiveSupport::ParameterFilter is not loaded by
|
|
954
|
+
# `require "active_support"` before 7.0, and a Rails 6.1 app without
|
|
955
|
+
# ActionDispatch::Request has not loaded it either; it has been
|
|
956
|
+
# requirable since 6.0.
|
|
957
|
+
def parameter_filter
|
|
958
|
+
filters = []
|
|
959
|
+
app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
960
|
+
filters.concat(Array(app.config.filter_parameters)) if app
|
|
961
|
+
registry = Permittable.filter_parameter_registry
|
|
962
|
+
filters << registry.to_proc unless registry.respond_to?(:include?) || registry.respond_to?(:names)
|
|
963
|
+
return if filters.empty?
|
|
964
|
+
|
|
965
|
+
begin
|
|
966
|
+
require "active_support/parameter_filter"
|
|
967
|
+
rescue LoadError
|
|
968
|
+
return
|
|
969
|
+
end
|
|
970
|
+
ActiveSupport::ParameterFilter.new(filters)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# The registry matches by substring, like Rails (`pin` also redacts
|
|
974
|
+
# `shipping`), so an expected and an actual hash that differ only under
|
|
975
|
+
# redacted names print the same. Say where they differ, by name only.
|
|
976
|
+
def differs_note
|
|
977
|
+
return "" unless @returning
|
|
978
|
+
|
|
979
|
+
expected = ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
|
|
980
|
+
actual = @result.to_h
|
|
981
|
+
return "" if expected == actual || printable(expected) != printable(actual)
|
|
982
|
+
|
|
983
|
+
# A filter on the root itself masks the whole hash, leaving the root as the one path.
|
|
984
|
+
paths = differing_paths(expected, actual, redacted(expected), redacted(actual)).map { |path| path || @root.to_s }
|
|
985
|
+
paths.empty? ? "" : "; differs at: #{paths.join(', ')}"
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Walks the real hashes beside their redacted copies and stops at the
|
|
989
|
+
# redaction boundary: a node the copy shows as masked is named by its
|
|
990
|
+
# path alone when the real values differ, and not entered, since a key
|
|
991
|
+
# or an index below it is as secret as its value.
|
|
992
|
+
def differing_paths(expected, actual, expected_masked, actual_masked, prefix = nil)
|
|
993
|
+
filtered = Permittable::FilterParameterRegistry::FILTERED
|
|
994
|
+
masked = expected_masked == filtered || actual_masked == filtered
|
|
995
|
+
if !masked && expected.is_a?(Hash) && actual.is_a?(Hash)
|
|
996
|
+
(expected.keys | actual.keys).flat_map do |key|
|
|
997
|
+
differing_paths(expected[key], actual[key], expected_masked[key], actual_masked[key], [prefix, key].compact.join("."))
|
|
998
|
+
end
|
|
999
|
+
elsif !masked && expected.is_a?(Array) && actual.is_a?(Array) && expected.length == actual.length
|
|
1000
|
+
expected.each_index.flat_map do |i|
|
|
1001
|
+
differing_paths(expected[i], actual[i], expected_masked[i], actual_masked[i], "#{prefix}[#{i}]")
|
|
1002
|
+
end
|
|
1003
|
+
else
|
|
1004
|
+
expected == actual ? [] : [prefix]
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
|
|
827
1008
|
def summary(violations)
|
|
828
1009
|
violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
|
|
829
1010
|
end
|
|
@@ -13,9 +13,10 @@ namespace :permittable do
|
|
|
13
13
|
|
|
14
14
|
# Every controller, not just the ones including Permittable — a controller
|
|
15
15
|
# that never included it is unguarded, which is exactly the finding. Bare
|
|
16
|
-
# ActionController::Metal ones included
|
|
17
|
-
|
|
16
|
+
# ActionController::Metal ones included, and autoloaded framework ones
|
|
17
|
+
# (Rails::HealthController) the routes name; see Audit.rails_controllers.
|
|
18
18
|
routes = Permittable::OpenAPI.rails_routes(Rails.application)
|
|
19
|
+
controllers = Permittable::Audit.rails_controllers(routes)
|
|
19
20
|
|
|
20
21
|
entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
|
|
21
22
|
stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
|
data/lib/permittable/version.rb
CHANGED