graphql-armory 0.1.0.pre8 → 0.1.0.pre9
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 +47 -0
- data/README.md +5 -5
- data/docs/authorization.md +88 -5
- data/docs/encrypted-ids.md +1 -1
- data/docs/persisted-queries.md +111 -19
- data/docs/rate-limiting.md +32 -11
- data/docs/telemetry.md +31 -8
- data/exe/graphql-armory-publish +6 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +7 -3
- data/lib/graphql/armory/authorization/adapters/pundit.rb +8 -1
- data/lib/graphql/armory/authorization/audit/baseline.rb +191 -0
- data/lib/graphql/armory/authorization/audit/command.rb +57 -8
- data/lib/graphql/armory/authorization/audit/findings.rb +3 -2
- data/lib/graphql/armory/authorization/audit/interface_rules.rb +4 -1
- data/lib/graphql/armory/authorization/audit/observed_rules.rb +58 -0
- data/lib/graphql/armory/authorization/audit/payload_reuse.rb +95 -0
- data/lib/graphql/armory/authorization/audit/plain_resolvers.rb +85 -0
- data/lib/graphql/armory/authorization/audit/reach.rb +75 -0
- data/lib/graphql/armory/authorization/audit/report.rb +7 -1
- data/lib/graphql/armory/authorization/audit/type_rules.rb +19 -2
- data/lib/graphql/armory/authorization/audit.rb +12 -4
- data/lib/graphql/armory/authorization/authorize.rb +28 -8
- data/lib/graphql/armory/authorization/configuration.rb +4 -3
- data/lib/graphql/armory/authorization/definitions.rb +7 -0
- data/lib/graphql/armory/authorization/integration/field.rb +26 -4
- data/lib/graphql/armory/authorization/integration/object.rb +8 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +2 -0
- data/lib/graphql/armory/authorization/integration.rb +14 -3
- data/lib/graphql/armory/authorization/rspec.rb +10 -4
- data/lib/graphql/armory/authorization/rule.rb +19 -3
- data/lib/graphql/armory/authorization/scope.rb +6 -6
- data/lib/graphql/armory/authorization.rb +1 -1
- data/lib/graphql/armory/ids.rb +11 -11
- data/lib/graphql/armory/persisted_queries/manifest.rb +2 -0
- data/lib/graphql/armory/persisted_queries/manifests.rb +69 -0
- data/lib/graphql/armory/persisted_queries/publisher.rb +127 -0
- data/lib/graphql/armory/persisted_queries/redis_store.rb +137 -0
- data/lib/graphql/armory/persisted_queries.rb +55 -16
- data/lib/graphql/armory/railtie.rb +10 -0
- data/lib/graphql/armory/rate_limits/analysis_guard.rb +79 -0
- data/lib/graphql/armory/rate_limits/concurrency.rb +91 -14
- data/lib/graphql/armory/rate_limits/points.rb +91 -14
- data/lib/graphql/armory/rate_limits/time.rb +107 -19
- data/lib/graphql/armory/rate_limits.rb +29 -5
- data/lib/graphql/armory/telemetry.rb +71 -9
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +1 -0
- data/lib/tasks/graphql_armory.rake +11 -4
- metadata +15 -3
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# A rule under `observe:` serves what it would refuse, so it is a problem until someone enforces it.
|
|
8
|
+
class ObservedRules
|
|
9
|
+
def self.call(schema, definitions, findings)
|
|
10
|
+
definitions.each do |type|
|
|
11
|
+
rule = type.armory_rule if type.respond_to?(:armory_rule)
|
|
12
|
+
report(findings, type.graphql_name, rule.observe, unobservable(type)) if rule&.observed?
|
|
13
|
+
next unless type.respond_to?(:fields)
|
|
14
|
+
|
|
15
|
+
Definitions.fields_at_every_level(type).each do |field|
|
|
16
|
+
reason = observed_reason(field)
|
|
17
|
+
report(findings, "#{type.graphql_name}.#{field.graphql_name}", reason, field.resolver ? field : nil) if reason
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
custom_introspection(schema).each { |field| report(findings, "#{field.owner.graphql_name}.#{field.graphql_name}", field.armory_observe) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.report(findings, path, reason, resolver_field = nil)
|
|
24
|
+
effect = if resolver_field
|
|
25
|
+
"raises on every query, because #{resolver_field.graphql_name} has a resolver or a mutation, which can write (#{reason}): remove observe:"
|
|
26
|
+
else
|
|
27
|
+
"serves what this rule would refuse (#{reason}): remove it to enforce the rule"
|
|
28
|
+
end
|
|
29
|
+
findings.problem(:observed, path, "observe: #{effect}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Its own observe:, or `authorize: true` on an owner whose rule is observed: if that owner is only a parent, no
|
|
33
|
+
# type reports its rule.
|
|
34
|
+
def self.observed_reason(field)
|
|
35
|
+
return field.armory_observe if field.respond_to?(:armory_observe) && field.armory_observe
|
|
36
|
+
return unless field.respond_to?(:armory_declared) && field.armory_declared == true
|
|
37
|
+
|
|
38
|
+
rule = field.owner.armory_rule if field.owner.respond_to?(:armory_rule)
|
|
39
|
+
rule.observe if rule&.observed?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.unobservable(type)
|
|
43
|
+
type.all_field_definitions.find(&:resolver)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :observed_reason, :unobservable
|
|
46
|
+
|
|
47
|
+
# Definitions holds what these fields return, not the classes that own them.
|
|
48
|
+
def self.custom_introspection(schema)
|
|
49
|
+
system = schema.introspection_system
|
|
50
|
+
(system.entry_points + system.dynamic_fields).select { |field| field.respond_to?(:armory_observe) && field.armory_observe }
|
|
51
|
+
end
|
|
52
|
+
private_class_method :custom_introspection
|
|
53
|
+
private_class_method :report
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# A payload's fields are covered by the mutation's rule, so they check nothing themselves. A field that returns the
|
|
8
|
+
# payload without being a mutation with a rule serves them with none run: a user the mutation refuses gets the data.
|
|
9
|
+
# It reads the definitions and not the schema's lookups, which filter by visibility and may need a profile.
|
|
10
|
+
class PayloadReuse
|
|
11
|
+
def self.call(definitions, findings)
|
|
12
|
+
new(findings).(definitions)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(findings = Findings.new)
|
|
16
|
+
@findings = findings
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(definitions)
|
|
20
|
+
objects = definitions.select { |type| type.kind.object? }
|
|
21
|
+
fields = objects.flat_map { |type| Definitions.fields_at_every_level(type).map { |field| [type, field] } }
|
|
22
|
+
guarded = fields.filter_map { |_type, field| field.resolver.payload_type if ruled_resolver?(field.resolver) }.uniq
|
|
23
|
+
payloads = objects.select do |type|
|
|
24
|
+
next false if own_rule?(type)
|
|
25
|
+
|
|
26
|
+
guarded.any? { |payload| type <= payload } || generated_by_ruled_mutation?(type) || holds_field_of_ruled_mutation?(type)
|
|
27
|
+
end
|
|
28
|
+
return if payloads.empty?
|
|
29
|
+
|
|
30
|
+
named = definitions.to_h { |type| [type.graphql_name, type] }
|
|
31
|
+
fields.each { |type, field| note(type, field, objects, payloads, named) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# graphql-ruby marks the payload it generates with its mutation, which the schema may not hold at all.
|
|
37
|
+
def generated_by_ruled_mutation?(type)
|
|
38
|
+
type.ancestors.any? { |ancestor| ancestor.respond_to?(:mutation) && ruled_resolver?(ancestor.mutation) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A field keeps the mutation that defined it as its owner, wherever it ends up.
|
|
42
|
+
def holds_field_of_ruled_mutation?(type)
|
|
43
|
+
Definitions.fields_at_every_level(type).any? do |field|
|
|
44
|
+
field.respond_to?(:armory_declared) && field.armory_declared.nil? && ruled_resolver?(field.owner)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ruled_resolver?(resolver)
|
|
49
|
+
return false unless resolver.respond_to?(:payload_type) && resolver.respond_to?(:armory_rule)
|
|
50
|
+
|
|
51
|
+
rule = resolver.armory_rule
|
|
52
|
+
!rule.nil? && !rule.none?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A type with a rule of its own checks itself wherever it is returned: `payload_type Types::Invoice`.
|
|
56
|
+
def own_rule?(type)
|
|
57
|
+
return false unless type.respond_to?(:armory_rule)
|
|
58
|
+
|
|
59
|
+
rule = type.armory_rule
|
|
60
|
+
!rule.nil? && !rule.none?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def note(type, field, objects, payloads, named)
|
|
64
|
+
target = returned_type(field, named) or return
|
|
65
|
+
reused = members(target, objects) & payloads
|
|
66
|
+
return if reused.empty? || own_payload?(field, target)
|
|
67
|
+
|
|
68
|
+
@findings.problem(:payload_outside_mutation, "#{type.graphql_name}.#{field.original_name}",
|
|
69
|
+
"returns #{reused.map(&:graphql_name).sort.join(", ")}, a mutation's payload, whose fields are covered by that mutation's rule: " \
|
|
70
|
+
"here no such rule runs, so they are served with no check. Return a type of its own, or, for a public child of a mutation with a rule, give the parent's field `authorize: :none, reason: \"...\"`")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A shadowed definition may name a type that never resolved; it cannot serve anything.
|
|
74
|
+
def returned_type(field, named)
|
|
75
|
+
type = field.type.unwrap
|
|
76
|
+
type.is_a?(GraphQL::Schema::LateBoundType) ? named[type.graphql_name] : type
|
|
77
|
+
rescue NameError, GraphQL::Error
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def members(target, objects)
|
|
82
|
+
return target.all_possible_types if target.kind.union?
|
|
83
|
+
return objects.select { |type| type <= target } if target.kind.interface?
|
|
84
|
+
|
|
85
|
+
[target]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def own_payload?(field, target)
|
|
89
|
+
ruled_resolver?(field.resolver) && field.resolver.payload_type == target
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# A type's covers_fields says a rule runs elsewhere, and on a resolver or mutation with no Base none does: it runs
|
|
8
|
+
# with no check. A plain method under the same type is the declaration's own meaning, and it is listed by the type.
|
|
9
|
+
module PlainResolvers
|
|
10
|
+
PROBLEM = "its resolver or mutation does not include GraphQL::Armory::Authorization::Base, so no rule of this gem runs on it and the type's covers_fields serves it, unless its own code refuses. " \
|
|
11
|
+
"Include Base and declare `authorize`, or declare `authorize: :none, reason: \"...\"` on the field"
|
|
12
|
+
PLAIN_FIELD_PROBLEM = "its resolver or mutation does not include GraphQL::Armory::Authorization::Base and neither does its field class, so no rule of this gem runs on it and the type's covers_fields " \
|
|
13
|
+
"serves it, unless its own code refuses. Include Base in the resolver and declare `authorize`, or use a field class that includes Base and declare `authorize: :none, reason: \"...\"`"
|
|
14
|
+
|
|
15
|
+
def self.report(findings, path, field)
|
|
16
|
+
findings.problem(:unprotected, path, field.respond_to?(:armory_declared) ? PROBLEM : PLAIN_FIELD_PROBLEM)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.covering?(rule)
|
|
20
|
+
!rule.nil? && rule.none? && rule.covers_fields?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A field that names its own rule, or that its parent mutation's rule already answered, does not depend on covers_fields.
|
|
24
|
+
# Under a write root, what it answers with comes after what it did, whichever class it is.
|
|
25
|
+
def self.open?(field, write_root: false)
|
|
26
|
+
resolver = field.resolver
|
|
27
|
+
return false if resolver.nil? || resolver.respond_to?(:armory_rule)
|
|
28
|
+
return false if field.respond_to?(:armory_declared) && !field.armory_declared.nil?
|
|
29
|
+
return false if ruled_owner?(field.owner)
|
|
30
|
+
|
|
31
|
+
write_root || !refused_by_its_type?(field, resolver)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.ruled_owner?(owner)
|
|
35
|
+
return false unless owner.is_a?(Class) && owner <= GraphQL::Schema::Resolver && owner.respond_to?(:armory_rule)
|
|
36
|
+
|
|
37
|
+
rule = owner.armory_rule
|
|
38
|
+
# With on_denied: :errors a refusal answers a live payload, so its fields still run.
|
|
39
|
+
!rule.nil? && !rule.none? && owner.armory_on_denied != :errors
|
|
40
|
+
end
|
|
41
|
+
private_class_method :ruled_owner?
|
|
42
|
+
|
|
43
|
+
# What a plain read answers with is refused by the rule of that type. A mutation has already written by then, and an
|
|
44
|
+
# interface may gain an implementer with no rule. A type that does not load yet counts as neither.
|
|
45
|
+
def self.refused_by_its_type?(field, resolver)
|
|
46
|
+
return false if resolver <= GraphQL::Schema::Mutation || resolver <= GraphQL::Schema::Subscription
|
|
47
|
+
|
|
48
|
+
ruled?(field.type.unwrap)
|
|
49
|
+
rescue GraphQL::Error, NameError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
private_class_method :refused_by_its_type?
|
|
53
|
+
|
|
54
|
+
def self.ruled?(element)
|
|
55
|
+
return generated_connection?(element) && ruled?(element.node_type) if Definitions.connection?(element)
|
|
56
|
+
|
|
57
|
+
if element.respond_to?(:kind) && element.kind.union?
|
|
58
|
+
members = element.type_memberships.map(&:object_type)
|
|
59
|
+
return !members.empty? && members.all? { |member| ruled?(member) }
|
|
60
|
+
end
|
|
61
|
+
return false unless element.is_a?(Class) && element.respond_to?(:armory_rule)
|
|
62
|
+
|
|
63
|
+
rule = element.armory_rule
|
|
64
|
+
!rule.nil? && !rule.none?
|
|
65
|
+
end
|
|
66
|
+
private_class_method :ruled?
|
|
67
|
+
|
|
68
|
+
# A field a connection or its edge adds is served by that class, whatever the rule on its nodes.
|
|
69
|
+
def self.generated_connection?(connection)
|
|
70
|
+
fields = Definitions.fields_at_every_level(connection)
|
|
71
|
+
edges = fields.select { |field| field.original_name == :edges }.map { |field| field.type.unwrap }
|
|
72
|
+
only_generated?(fields, GENERATED_RELAY_FIELDS.fetch(GraphQL::Types::Relay::BaseConnection)) &&
|
|
73
|
+
edges.all? { |edge| only_generated?(Definitions.fields_at_every_level(edge), GENERATED_RELAY_FIELDS.fetch(GraphQL::Types::Relay::BaseEdge)) }
|
|
74
|
+
end
|
|
75
|
+
private_class_method :generated_connection?
|
|
76
|
+
|
|
77
|
+
def self.only_generated?(fields, names)
|
|
78
|
+
fields.all? { |field| names.include?(field.original_name) && field.resolver.nil? }
|
|
79
|
+
end
|
|
80
|
+
private_class_method :only_generated?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# What a problem reaches beyond its own name: every definition that is, inherits from or implements what its path
|
|
8
|
+
# names, with their fields and `loads:`. A problem on a type or a base class says nothing about what is added to it
|
|
9
|
+
# later, so a baseline compares this to tell the problem someone accepted from a larger one.
|
|
10
|
+
class Reach
|
|
11
|
+
LIBRARY = [::Object, ::BasicObject, ::Kernel, ::Comparable].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(definitions)
|
|
14
|
+
@definitions = definitions
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(path)
|
|
18
|
+
head, member = path.delete_suffix(")").split(/[.(]/)
|
|
19
|
+
@definitions.select { |type| holds?(type, head) }.flat_map { |type| of(type, member) }.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def holds?(type, head)
|
|
25
|
+
type.graphql_name == head || type.ancestors.any? { |ancestor| app_class?(ancestor) && (ancestor.name == head || named?(ancestor, head)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ruby's and graphql-ruby's own classes are in every type's ancestors, and graphql-ruby's answer `graphql_name` too: a
|
|
29
|
+
# type named `Object` or `Member` must not reach everything.
|
|
30
|
+
def app_class?(ancestor)
|
|
31
|
+
!LIBRARY.include?(ancestor) && !ancestor.name&.start_with?("GraphQL::")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def named?(ancestor, head)
|
|
35
|
+
ancestor.respond_to?(:graphql_name) && ancestor.graphql_name == head
|
|
36
|
+
rescue GraphQL::Error
|
|
37
|
+
false # an anonymous class with no name of its own
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A problem on one field reaches that field: the others of its type have their own problems.
|
|
41
|
+
def of(type, member)
|
|
42
|
+
label = type.name || type.graphql_name
|
|
43
|
+
return ["type:#{label}", *input_loads(label, type)] if type.kind.input_object?
|
|
44
|
+
return ["type:#{label}"] unless type.respond_to?(:fields)
|
|
45
|
+
|
|
46
|
+
fields = Definitions.fields_at_every_level(type)
|
|
47
|
+
fields = fields.select { |field| field.graphql_name == member || field.original_name.to_s == member } if member
|
|
48
|
+
["type:#{label}", *fields.flat_map { |field| of_field(label, field) }]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def input_loads(label, type)
|
|
52
|
+
type.all_argument_definitions.filter_map { |argument| argument.loads && "loads:#{label}.#{argument.graphql_name}:#{argument.loads.graphql_name}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def of_field(label, field)
|
|
56
|
+
["field:#{label}.#{field.graphql_name}:#{signature(field)}",
|
|
57
|
+
*field.all_argument_definitions.filter_map { |argument| loads_of(label, field, argument) }]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A field whose type cannot be read is what some problems are about: it must not stop the audit.
|
|
61
|
+
def signature(field)
|
|
62
|
+
field.type.to_type_signature
|
|
63
|
+
rescue StandardError, LoadError
|
|
64
|
+
"unreadable"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def loads_of(label, field, argument)
|
|
68
|
+
loads = argument.loads
|
|
69
|
+
"loads:#{label}.#{field.graphql_name}.#{argument.graphql_name}:#{loads.graphql_name}" if loads
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -16,12 +16,18 @@ module GraphQL
|
|
|
16
16
|
|
|
17
17
|
attr_reader :problems, :bypasses
|
|
18
18
|
|
|
19
|
-
def initialize(problems:, bypasses:)
|
|
19
|
+
def initialize(problems:, bypasses:, reach: nil)
|
|
20
20
|
@problems = problems.freeze
|
|
21
21
|
@bypasses = bypasses.freeze
|
|
22
|
+
@reach = reach
|
|
22
23
|
freeze
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
# What a problem reaches beyond its own name: see Reach. Worked out only for a baseline, so a plain audit never reads it.
|
|
27
|
+
def reach_of(problem)
|
|
28
|
+
@reach ? @reach.(problem.path) : []
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
def ok?
|
|
26
32
|
problems.empty?
|
|
27
33
|
end
|
|
@@ -6,7 +6,8 @@ module GraphQL
|
|
|
6
6
|
class Audit
|
|
7
7
|
# An object type's rule, and what its fields, resolvers and arguments declare under it.
|
|
8
8
|
class TypeRules
|
|
9
|
-
def initialize(adapter = Adapters::Null.new, findings = Findings.new, foreign_policies = ForeignPolicies.new(adapter, findings))
|
|
9
|
+
def initialize(adapter = Adapters::Null.new, findings = Findings.new, foreign_policies = ForeignPolicies.new(adapter, findings), write_roots = [])
|
|
10
|
+
@write_roots = write_roots
|
|
10
11
|
@adapter = adapter
|
|
11
12
|
@findings = findings
|
|
12
13
|
@foreign_policies = foreign_policies
|
|
@@ -87,6 +88,7 @@ module GraphQL
|
|
|
87
88
|
audit_loads(field, path)
|
|
88
89
|
# An inherited interface field is reported as uncovered on its interface; every other check still runs here.
|
|
89
90
|
inherited = bypassed && interface_field?(field)
|
|
91
|
+
PlainResolvers.report(@findings, path, field) if served_with_no_rule?(type, field)
|
|
90
92
|
if bypassed && !inherited && !field.respond_to?(:armory_declared)
|
|
91
93
|
return @findings.problem(:field_without_rule, path, "its field class does not include Base, so no rule runs on it: its type is bypassed and graphql-ruby's own " \
|
|
92
94
|
"authorized? answers true, so the field is served with no check. Use a field class that includes Base")
|
|
@@ -100,6 +102,13 @@ module GraphQL
|
|
|
100
102
|
audit_arguments(field.all_argument_definitions, path, reported: reported)
|
|
101
103
|
end
|
|
102
104
|
|
|
105
|
+
# Asked of the type that serves it and of the class that defines it, as runtime does: a parent that does not cover it denies it.
|
|
106
|
+
def served_with_no_rule?(type, field)
|
|
107
|
+
return false unless PlainResolvers.covering?(type.armory_rule) && !interface_field?(field) && PlainResolvers.open?(field, write_root: @write_roots.include?(type))
|
|
108
|
+
|
|
109
|
+
!(field.respond_to?(:armory_denied_by_owner?) && field.armory_denied_by_owner?)
|
|
110
|
+
end
|
|
111
|
+
|
|
103
112
|
# A field runs under its owner: graphql-ruby never tells it the concrete type. A rule defined above that type asks
|
|
104
113
|
# the owner's model, or only the record's class, while this audit reads the type's model.
|
|
105
114
|
def audit_inherited_rule(type, field, path, model)
|
|
@@ -153,7 +162,10 @@ module GraphQL
|
|
|
153
162
|
when :none
|
|
154
163
|
@findings.bypass(path, field.armory_reason)
|
|
155
164
|
when nil, true
|
|
156
|
-
if
|
|
165
|
+
if denied_payload_field?(field)
|
|
166
|
+
@findings.problem(:field_without_rule, path, "declares no rule and #{name_of(field.owner)}, which defines it, has none that covers it, so the mutation runs and this field answers null: " \
|
|
167
|
+
"a client cannot tell whether it worked. Give the field `authorize: :none, reason: \"...\"`, or use a shared `payload_type`; a public mutation adds `covers_fields: true`")
|
|
168
|
+
elsif bypassed && declared.nil? && served_by_owner?(type, field)
|
|
157
169
|
@findings.problem(:field_without_rule, path, "declares no rule and its type is bypassed, but it is inherited from #{field.owner.name || field.owner.inspect}: a field runs " \
|
|
158
170
|
"under the class that defines it, and no rule ran on this type, so the field is served with no check. Give it authorize: where it is defined, or remove it")
|
|
159
171
|
elsif bypassed
|
|
@@ -164,6 +176,11 @@ module GraphQL
|
|
|
164
176
|
end
|
|
165
177
|
end
|
|
166
178
|
|
|
179
|
+
def denied_payload_field?(field)
|
|
180
|
+
owner = field.owner
|
|
181
|
+
owner.is_a?(Class) && owner <= GraphQL::Schema::Resolver && field.armory_denied_by_owner?
|
|
182
|
+
end
|
|
183
|
+
|
|
167
184
|
# A field runs under its owner's rule, not the resolved type's. Inherited by a bypassed type from a parent whose
|
|
168
185
|
# rule covers it, or that knows no rule at all, graphql-ruby serves it after a type check that checked nothing.
|
|
169
186
|
def served_by_owner?(type, field)
|
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
4
|
require "graphql/armory/authorization/audit/report"
|
|
5
|
+
require "graphql/armory/authorization/audit/baseline"
|
|
6
|
+
require "graphql/armory/authorization/audit/reach"
|
|
5
7
|
require "graphql/armory/authorization/audit/command"
|
|
6
8
|
require "graphql/armory/authorization/audit/findings"
|
|
7
9
|
require "graphql/armory/authorization/audit/library_methods"
|
|
8
10
|
require "graphql/armory/authorization/audit/denial_hooks"
|
|
9
11
|
require "graphql/armory/authorization/audit/own_checks"
|
|
12
|
+
require "graphql/armory/authorization/audit/plain_resolvers"
|
|
10
13
|
require "graphql/armory/authorization/audit/overrides"
|
|
11
14
|
require "graphql/armory/authorization/audit/foreign_policies"
|
|
12
15
|
require "graphql/armory/authorization/audit/type_rules"
|
|
16
|
+
require "graphql/armory/authorization/audit/observed_rules"
|
|
13
17
|
require "graphql/armory/authorization/audit/edge_rules"
|
|
14
18
|
require "graphql/armory/authorization/audit/interface_rules"
|
|
19
|
+
require "graphql/armory/authorization/audit/payload_reuse"
|
|
15
20
|
require "graphql/armory/authorization/audit/input_loads"
|
|
16
21
|
require "graphql/armory/authorization/audit/visibility_models"
|
|
17
22
|
require "graphql/armory/authorization/audit/introspection_fields"
|
|
@@ -64,16 +69,18 @@ module GraphQL
|
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
foreign_policies = ForeignPolicies.new(adapter, findings)
|
|
67
|
-
type_rules = TypeRules.new(adapter, findings, foreign_policies)
|
|
72
|
+
type_rules = TypeRules.new(adapter, findings, foreign_policies, [schema.mutation, schema.subscription].compact)
|
|
68
73
|
audited_types(definitions).each { |type| type_rules.(type) }
|
|
69
74
|
definitions.each { |type| foreign_policies.relay(type) if type.kind.object? && RELAY_TYPES.any? { |relay| type <= relay } }
|
|
70
75
|
EdgeRules.(definitions, findings)
|
|
71
76
|
InterfaceRules.(definitions, findings)
|
|
72
77
|
InputLoads.(definitions, findings)
|
|
78
|
+
PayloadReuse.(definitions, findings)
|
|
79
|
+
ObservedRules.(schema, definitions, findings)
|
|
73
80
|
VisibilityModels.(schema, definitions, findings)
|
|
74
81
|
IntrospectionFields.(schema, findings)
|
|
75
82
|
findings.problem(:model, "Schema", listing_error.message) if listing_error && !findings.problem?(:model)
|
|
76
|
-
findings.report
|
|
83
|
+
findings.report(Reach.new(definitions))
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
private
|
|
@@ -84,10 +91,11 @@ module GraphQL
|
|
|
84
91
|
Definitions.fields_at_every_level(type).any? { |field| !field.owner.equal?(GraphQL::Types::Relay::PageInfo) }
|
|
85
92
|
end
|
|
86
93
|
|
|
87
|
-
# A connection or an edge holds only what graphql-ruby generates, until a subclass, its parent or an interface adds a field
|
|
94
|
+
# A connection or an edge holds only what graphql-ruby generates, until a subclass, its parent or an interface adds a field,
|
|
95
|
+
# or gives a generated name a resolver of its own.
|
|
88
96
|
def extended_relay?(type)
|
|
89
97
|
GENERATED_RELAY_FIELDS.any? do |relay, names|
|
|
90
|
-
type < relay && Definitions.fields_at_every_level(type).any? { |field| !names.include?(field.original_name) }
|
|
98
|
+
type < relay && Definitions.fields_at_every_level(type).any? { |field| !names.include?(field.original_name) || field.resolver }
|
|
91
99
|
end
|
|
92
100
|
end
|
|
93
101
|
|
|
@@ -14,24 +14,42 @@ module GraphQL
|
|
|
14
14
|
@telemetry = telemetry
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def self.build(context, adapter:, sinks: [])
|
|
18
|
-
new(adapter: adapter, memo: Memo.build(context), telemetry: Telemetry.build(sinks: sinks))
|
|
17
|
+
def self.build(context, adapter:, sinks: [], on_sink_error: nil)
|
|
18
|
+
new(adapter: adapter, memo: Memo.build(context), telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def self.call(subject, rule, context, adapter:, sinks: [])
|
|
22
|
-
build(context, adapter: adapter, sinks: sinks).(subject, rule, context)
|
|
21
|
+
def self.call(subject, rule, context, adapter:, sinks: [], on_sink_error: nil)
|
|
22
|
+
build(context, adapter: adapter, sinks: sinks, on_sink_error: on_sink_error).(subject, rule, context)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def call(subject, rule, context)
|
|
26
|
-
return bypass(subject, rule) if rule.none?
|
|
26
|
+
return bypass(subject, rule, context) if rule.none?
|
|
27
27
|
|
|
28
28
|
memo.fetch(subject, rule) { decide(subject, rule, context) }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
|
-
def bypass(subject, rule)
|
|
34
|
-
telemetry.record(:bypassed, type: class_name(subject), reason: rule.reason)
|
|
33
|
+
def bypass(subject, rule, context)
|
|
34
|
+
telemetry.record(:bypassed, context: context, type: class_name(subject), reason: rule.reason)
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Observing opens one thing only: a clear "no" to a read, with someone listening, from an adapter someone configured.
|
|
39
|
+
# A mutation or a subscription keeps refusing, even on a read inside its response: only a query runs nothing it cannot undo.
|
|
40
|
+
def observes?(rule, context)
|
|
41
|
+
rule.observed? && telemetry.listening? && !adapter.is_a?(Adapters::Null) && read_only?(context)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A partial always answers `query?`, whatever the operation that ran it: only the query it came from can tell.
|
|
45
|
+
def read_only?(context)
|
|
46
|
+
query = context.query if context.respond_to?(:query)
|
|
47
|
+
query = query.query while defined?(GraphQL::Query::Partial) && query.is_a?(GraphQL::Query::Partial)
|
|
48
|
+
query.respond_to?(:query?) && query.query? == true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def observe(subject, rule, context)
|
|
52
|
+
telemetry.record(:observed, context: context, type: class_name(subject), action: rule.action, reason: rule.observe)
|
|
35
53
|
true
|
|
36
54
|
end
|
|
37
55
|
|
|
@@ -45,8 +63,10 @@ module GraphQL
|
|
|
45
63
|
|
|
46
64
|
case answer
|
|
47
65
|
when true, false, nil
|
|
66
|
+
return observe(subject, rule, context) if answer != true && observes?(rule, context)
|
|
67
|
+
|
|
48
68
|
allowed = answer == true
|
|
49
|
-
telemetry.record(allowed ? :allowed : :denied, type: class_name(subject), action: rule.action)
|
|
69
|
+
telemetry.record(allowed ? :allowed : :denied, context: context, type: class_name(subject), action: rule.action)
|
|
50
70
|
allowed
|
|
51
71
|
else
|
|
52
72
|
# The class alone: the answer may be the record, and on a relation `inspect` runs the query.
|
|
@@ -11,9 +11,9 @@ module GraphQL
|
|
|
11
11
|
|
|
12
12
|
OWN_CHECKS = %i[authorized? authorizes? authorized_new wrap unauthorized_object visible? scope_items reauthorize_scoped_objects].freeze
|
|
13
13
|
|
|
14
|
-
attr_reader :adapter, :on_unauthorized, :sinks, :own_denial_hooks, :own_checks
|
|
14
|
+
attr_reader :adapter, :on_unauthorized, :sinks, :on_sink_error, :own_denial_hooks, :own_checks
|
|
15
15
|
|
|
16
|
-
def initialize(adapter:, on_unauthorized: :null, sinks: [], own_denial_hooks: {}, own_checks: {})
|
|
16
|
+
def initialize(adapter:, on_unauthorized: :null, sinks: [], on_sink_error: nil, own_denial_hooks: {}, own_checks: {})
|
|
17
17
|
unless MODES.include?(on_unauthorized)
|
|
18
18
|
raise ArgumentError, "on_unauthorized must be one of #{MODES.inspect}, got #{on_unauthorized.inspect}"
|
|
19
19
|
end
|
|
@@ -24,8 +24,9 @@ module GraphQL
|
|
|
24
24
|
@adapter = resolve(adapter)
|
|
25
25
|
@on_unauthorized = on_unauthorized
|
|
26
26
|
@own_denial_hooks = own_denial_hooks.to_h { |hook, reason| [hook, reason.dup.freeze] }.freeze
|
|
27
|
-
Telemetry.build(sinks: sinks) # raises at boot when the sinks are not callables
|
|
27
|
+
Telemetry.build(sinks: sinks, on_sink_error: on_sink_error) # raises at boot when the sinks are not callables
|
|
28
28
|
@sinks = sinks
|
|
29
|
+
@on_sink_error = on_sink_error
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
private
|
|
@@ -66,12 +66,19 @@ module GraphQL
|
|
|
66
66
|
references << arguments_of(type) if type.kind.input_object?
|
|
67
67
|
references.concat(type.interface_type_memberships.map(&:abstract_type)) if type.respond_to?(:interface_type_memberships)
|
|
68
68
|
references.concat(type.orphan_types) if type.kind.interface?
|
|
69
|
+
references.concat(included_interfaces(type)) if type.kind.interface?
|
|
69
70
|
references.concat(type.type_memberships.map(&:object_type)) if type.kind.union?
|
|
70
71
|
references << type.node_type if connection?(type)
|
|
71
72
|
references.flatten.compact
|
|
72
73
|
end
|
|
73
74
|
private_class_method :referenced_types
|
|
74
75
|
|
|
76
|
+
# An interface brought in with `include` makes no type membership, and its fields are served all the same.
|
|
77
|
+
def self.included_interfaces(type)
|
|
78
|
+
type.ancestors.select { |ancestor| !ancestor.equal?(type) && ancestor.respond_to?(:kind) && ancestor.kind.interface? }
|
|
79
|
+
end
|
|
80
|
+
private_class_method :included_interfaces
|
|
81
|
+
|
|
75
82
|
# Every definition of every field, nearest first: a hidden one falls back to the next of its name, and
|
|
76
83
|
# `all_field_definitions` merges them by name. `field ... do ... end` blocks are loaded here, because only
|
|
77
84
|
# `schema.types` or a request loads them, and the audit reads arguments from them.
|
|
@@ -10,6 +10,7 @@ module GraphQL
|
|
|
10
10
|
# field :id, ID, authorize: :none, reason: "ids are public" # declared bypass
|
|
11
11
|
# field :owner_id, ID, authorize: true # the parent type's own action
|
|
12
12
|
# field :notes, String, authorize: :manage, visible_when: :export # hidden from users who cannot export
|
|
13
|
+
# field :notes, String, authorize: :manage, observe: "rollout" # served and recorded, see Authorize
|
|
13
14
|
#
|
|
14
15
|
# A field with no rule is covered by its type's rule, unless the type is bypassed:
|
|
15
16
|
# coverage does not propagate from a bypass, so that field is denied.
|
|
@@ -19,9 +20,16 @@ module GraphQL
|
|
|
19
20
|
module Field
|
|
20
21
|
# `find_by: :id` looks the object up inside the user's scope instead of calling the resolver.
|
|
21
22
|
# `loads:` arguments also load through the scope unless `load_through_scope: false`.
|
|
22
|
-
def initialize(*args, authorize: nil, reason: nil, find_by: nil, load_through_scope: true, visible_when: nil, override_reason: nil, **kwargs, &block)
|
|
23
|
+
def initialize(*args, authorize: nil, reason: nil, observe: nil, find_by: nil, load_through_scope: true, visible_when: nil, override_reason: nil, **kwargs, &block)
|
|
23
24
|
raise ConfigurationError, "override_reason: must say why the override is safe" if !override_reason.nil? && override_reason.to_s.strip.empty?
|
|
24
25
|
|
|
26
|
+
if !observe.nil? && (!authorize.is_a?(Symbol) || authorize == :none)
|
|
27
|
+
raise ArgumentError, "observe: needs an action of the field's own: `authorize: :manage, observe: \"...\"`"
|
|
28
|
+
end
|
|
29
|
+
raise ArgumentError, "observe: does not apply to a field with a resolver or a mutation: what it runs cannot be undone" if !observe.nil? && kwargs[:resolver_class]
|
|
30
|
+
Rule.new(authorize, observe: observe) unless observe.nil?
|
|
31
|
+
|
|
32
|
+
@armory_observe = observe
|
|
25
33
|
@armory_override_reason = override_reason
|
|
26
34
|
@armory_declared = authorize
|
|
27
35
|
@armory_reason = reason
|
|
@@ -32,7 +40,7 @@ module GraphQL
|
|
|
32
40
|
extension(Integration::FindBy, key: find_by) if find_by
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
attr_reader :armory_declared, :armory_reason, :armory_visible_when, :armory_override_reason
|
|
43
|
+
attr_reader :armory_declared, :armory_reason, :armory_observe, :armory_visible_when, :armory_override_reason
|
|
36
44
|
|
|
37
45
|
def armory_load_through_scope?
|
|
38
46
|
@armory_load_through_scope
|
|
@@ -72,10 +80,24 @@ module GraphQL
|
|
|
72
80
|
def authorized?(object, args, context)
|
|
73
81
|
rule = armory_rule_for(owner, context)
|
|
74
82
|
return false if rule.nil?
|
|
83
|
+
if resolver && rule.is_a?(Rule) && rule.observed?
|
|
84
|
+
raise ConfigurationError, "#{owner.graphql_name}.#{graphql_name} has a resolver or a mutation, which can write, so it cannot use an observed rule"
|
|
85
|
+
end
|
|
75
86
|
|
|
76
87
|
(rule == :covered || Integration.allowed?(object, Integration.with_owner_model(rule, owner), context)) && super
|
|
77
88
|
end
|
|
78
89
|
|
|
90
|
+
# Asked of a field with no resolver. True when it declares no rule of its own and the class that declared it does not cover it. It asks that class
|
|
91
|
+
# and not the type being served: a field a payload inherits keeps the parent mutation as its owner.
|
|
92
|
+
def armory_denied_by_owner?
|
|
93
|
+
return false unless [nil, true].include?(@armory_declared) && owner.respond_to?(:armory_rule)
|
|
94
|
+
|
|
95
|
+
rule = owner.armory_rule
|
|
96
|
+
return true if rule.nil?
|
|
97
|
+
|
|
98
|
+
@armory_declared.nil? ? rule.none? && !rule.covers_fields? : rule.none?
|
|
99
|
+
end
|
|
100
|
+
|
|
79
101
|
private
|
|
80
102
|
|
|
81
103
|
# A resolver or mutation that includes Base enforces its own rule, so the field is covered by it.
|
|
@@ -94,13 +116,13 @@ module GraphQL
|
|
|
94
116
|
return Integration.bypassed_implementer?(type, context.schema) ? nil : :covered if type.respond_to?(:kind) && type.kind.interface?
|
|
95
117
|
return :covered unless type.respond_to?(:armory_rule)
|
|
96
118
|
|
|
97
|
-
|
|
119
|
+
armory_denied_by_owner? ? nil : :covered
|
|
98
120
|
when true
|
|
99
121
|
type_rule unless type_rule&.none?
|
|
100
122
|
when :none
|
|
101
123
|
Rule.none(reason: @armory_reason)
|
|
102
124
|
else
|
|
103
|
-
Rule.new(@armory_declared, policy: armory_policy, attribute: original_name)
|
|
125
|
+
Rule.new(@armory_declared, policy: armory_policy, attribute: original_name, observe: @armory_observe)
|
|
104
126
|
end
|
|
105
127
|
end
|
|
106
128
|
end
|
|
@@ -60,7 +60,15 @@ module GraphQL
|
|
|
60
60
|
rule = armory_rule
|
|
61
61
|
return false if rule.nil?
|
|
62
62
|
|
|
63
|
+
Integration.refuse_unobservable(self, context) if rule.observed?
|
|
63
64
|
Integration.allowed?(object, Integration.with_owner_model(rule, self), context) && super
|
|
65
|
+
rescue GraphQL::UnauthorizedError
|
|
66
|
+
raise
|
|
67
|
+
rescue StandardError => error
|
|
68
|
+
# graphql-ruby reads what a rescue_from answers here as the authorization, on both engines, so a handler that logs
|
|
69
|
+
# and answers true would serve the object. The handler runs, its answer does not count. No public hook does this.
|
|
70
|
+
context.query.handle_or_reraise(error)
|
|
71
|
+
false
|
|
64
72
|
end
|
|
65
73
|
end
|
|
66
74
|
end
|
|
@@ -21,6 +21,8 @@ module GraphQL
|
|
|
21
21
|
def authorize(action, reason: nil, on_denied: :default, **options)
|
|
22
22
|
raise ArgumentError, "on_denied must be one of #{ON_DENIED.inspect}, got #{on_denied.inspect}" unless ON_DENIED.include?(on_denied)
|
|
23
23
|
|
|
24
|
+
raise ArgumentError, "observe: does not apply to a mutation or a resolver: what it runs cannot be undone" if options.key?(:observe)
|
|
25
|
+
|
|
24
26
|
@armory_on_denied = on_denied
|
|
25
27
|
@armory_rule = Rule.new(action, reason: reason, **options)
|
|
26
28
|
end
|