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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +5 -5
  4. data/docs/authorization.md +88 -5
  5. data/docs/encrypted-ids.md +1 -1
  6. data/docs/persisted-queries.md +111 -19
  7. data/docs/rate-limiting.md +32 -11
  8. data/docs/telemetry.md +31 -8
  9. data/exe/graphql-armory-publish +6 -0
  10. data/lib/graphql/armory/authorization/adapters/cancan.rb +7 -3
  11. data/lib/graphql/armory/authorization/adapters/pundit.rb +8 -1
  12. data/lib/graphql/armory/authorization/audit/baseline.rb +191 -0
  13. data/lib/graphql/armory/authorization/audit/command.rb +57 -8
  14. data/lib/graphql/armory/authorization/audit/findings.rb +3 -2
  15. data/lib/graphql/armory/authorization/audit/interface_rules.rb +4 -1
  16. data/lib/graphql/armory/authorization/audit/observed_rules.rb +58 -0
  17. data/lib/graphql/armory/authorization/audit/payload_reuse.rb +95 -0
  18. data/lib/graphql/armory/authorization/audit/plain_resolvers.rb +85 -0
  19. data/lib/graphql/armory/authorization/audit/reach.rb +75 -0
  20. data/lib/graphql/armory/authorization/audit/report.rb +7 -1
  21. data/lib/graphql/armory/authorization/audit/type_rules.rb +19 -2
  22. data/lib/graphql/armory/authorization/audit.rb +12 -4
  23. data/lib/graphql/armory/authorization/authorize.rb +28 -8
  24. data/lib/graphql/armory/authorization/configuration.rb +4 -3
  25. data/lib/graphql/armory/authorization/definitions.rb +7 -0
  26. data/lib/graphql/armory/authorization/integration/field.rb +26 -4
  27. data/lib/graphql/armory/authorization/integration/object.rb +8 -0
  28. data/lib/graphql/armory/authorization/integration/resolver.rb +2 -0
  29. data/lib/graphql/armory/authorization/integration.rb +14 -3
  30. data/lib/graphql/armory/authorization/rspec.rb +10 -4
  31. data/lib/graphql/armory/authorization/rule.rb +19 -3
  32. data/lib/graphql/armory/authorization/scope.rb +6 -6
  33. data/lib/graphql/armory/authorization.rb +1 -1
  34. data/lib/graphql/armory/ids.rb +11 -11
  35. data/lib/graphql/armory/persisted_queries/manifest.rb +2 -0
  36. data/lib/graphql/armory/persisted_queries/manifests.rb +69 -0
  37. data/lib/graphql/armory/persisted_queries/publisher.rb +127 -0
  38. data/lib/graphql/armory/persisted_queries/redis_store.rb +137 -0
  39. data/lib/graphql/armory/persisted_queries.rb +55 -16
  40. data/lib/graphql/armory/railtie.rb +10 -0
  41. data/lib/graphql/armory/rate_limits/analysis_guard.rb +79 -0
  42. data/lib/graphql/armory/rate_limits/concurrency.rb +91 -14
  43. data/lib/graphql/armory/rate_limits/points.rb +91 -14
  44. data/lib/graphql/armory/rate_limits/time.rb +107 -19
  45. data/lib/graphql/armory/rate_limits.rb +29 -5
  46. data/lib/graphql/armory/telemetry.rb +71 -9
  47. data/lib/graphql/armory/version.rb +1 -1
  48. data/lib/graphql/armory.rb +1 -0
  49. data/lib/tasks/graphql_armory.rake +11 -4
  50. 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 bypassed && declared.nil? && served_by_owner?(type, field)
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
- type_rule && (!type_rule.none? || type_rule.covers_fields?) ? :covered : nil
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