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
@@ -25,6 +25,16 @@ module GraphQL
25
25
  # What a refused request holds: nothing to run.
26
26
  EMPTY_DOCUMENT = GraphQL::Language::Nodes::Document.new(definitions: [])
27
27
 
28
+ # What a client reads in `extensions.code`, so an app can tell a refusal from a query that failed for another reason.
29
+ REFUSAL_CODES = { UNKNOWN_OPERATION => "UNKNOWN_OPERATION", MISMATCH => "OPERATION_MISMATCH", RAW_QUERY_REFUSED => "RAW_QUERY_REFUSED" }.freeze
30
+
31
+ # A validation error with its code.
32
+ class RefusalError < GraphQL::StaticValidation::Error
33
+ def to_h
34
+ super.merge("extensions" => { "code" => REFUSAL_CODES.fetch(message, "OPERATION_REFUSED") })
35
+ end
36
+ end
37
+
28
38
  # Validates nothing and answers the refusal, through the query's public `static_validator:` option.
29
39
  class Refusal < GraphQL::StaticValidation::Validator
30
40
  def initialize(schema, message)
@@ -33,14 +43,14 @@ module GraphQL
33
43
  end
34
44
 
35
45
  def validate(_query, **)
36
- { errors: [GraphQL::StaticValidation::Error.new(@message)], remaining_timeout: nil }
46
+ { errors: [RefusalError.new(@message)], remaining_timeout: nil }
37
47
  end
38
48
  end
39
49
 
40
- # `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, or your own.
50
+ # `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, a RedisStore, or your own.
41
51
  # `strict: true` refuses raw queries unless the context holds `armory_allow_raw_query: true`.
42
- def self.use(schema, store:, strict: false, sinks: [])
43
- gate = Gate.build(store: store, strict: strict, sinks: sinks)
52
+ def self.use(schema, store:, strict: false, sinks: [], on_sink_error: nil)
53
+ gate = Gate.build(store: store, strict: strict, sinks: sinks, on_sink_error: on_sink_error)
44
54
 
45
55
  if schema.singleton_class.include?(Guard)
46
56
  raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
@@ -49,6 +59,7 @@ module GraphQL
49
59
  schema.instance_variable_set(:@armory_persisted_queries, gate)
50
60
  schema.singleton_class.prepend(Guard)
51
61
  schema.query_class(schema.query_class)
62
+ schema.trace_with(BuiltQueries)
52
63
  end
53
64
 
54
65
  # Decides what a request runs: answers `[refusal, document]`, where a refusal is the error message, a
@@ -56,8 +67,8 @@ module GraphQL
56
67
  # as it came.
57
68
  class Gate
58
69
  # Wires the app's store with `use`'s defaults; `use` goes through here.
59
- def self.build(store:, strict: false, sinks: [])
60
- new(store: store, strict: strict, telemetry: Telemetry.build(sinks: sinks))
70
+ def self.build(store:, strict: false, sinks: [], on_sink_error: nil)
71
+ new(store: store, strict: strict, telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
61
72
  end
62
73
 
63
74
  # `new` is inert: with no store wired it refuses every request. It checks what it is given, so it never
@@ -67,7 +78,7 @@ module GraphQL
67
78
  raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
68
79
  end
69
80
  raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
70
- raise ConfigurationError, "strict: false needs a store: with the Null store only raw queries would run" if !strict && store.is_a?(Store::Null)
81
+ raise ConfigurationError, "strict: false needs a store that holds operations: with an empty one only raw queries would run" if !strict && (store.is_a?(Store::Null) || (store.respond_to?(:empty?) && store.empty?))
71
82
 
72
83
  @store = store
73
84
  @strict = strict
@@ -76,30 +87,40 @@ module GraphQL
76
87
 
77
88
  def call(context, text, document)
78
89
  operation_id = context[:operation_id] if context.respond_to?(:[])
90
+ sink_context = context.respond_to?(:[]) ? context : {}
79
91
 
80
92
  if !operation_id.nil?
81
93
  operation = operation_id.is_a?(String) ? @store.fetch(operation_id) : nil
82
94
  if operation.nil?
83
- refuse(UNKNOWN_OPERATION, :unknown_operation)
95
+ refuse(UNKNOWN_OPERATION, :unknown_operation, sink_context)
84
96
  elsif document || (text && !same_query?(text, operation))
85
- refuse(MISMATCH, :mismatch)
97
+ refuse(MISMATCH, :mismatch, sink_context)
86
98
  else
87
- @telemetry.record(:operation_persisted)
99
+ @telemetry.record(:operation_persisted, context: sink_context)
88
100
  [nil, operation.document]
89
101
  end
90
102
  elsif @strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
91
- refuse(RAW_QUERY_REFUSED, :raw_query)
103
+ refuse(RAW_QUERY_REFUSED, :raw_query, sink_context)
92
104
  else
93
- @telemetry.record(@strict ? :raw_query_allowed : :raw_query_run) if text || document
105
+ @telemetry.record(@strict ? :raw_query_allowed : :raw_query_run, context: sink_context) if text || document
94
106
  [nil, nil]
95
107
  end
96
108
  end
97
109
 
110
+ # A query the app built itself, which never went through the lookup. It runs its own text, so an operation id means
111
+ # nothing on it. Answers the refusal, or nil to let it run.
112
+ def built_query(context)
113
+ return refuse(RAW_QUERY_REFUSED, :raw_query, context).first if @strict && context[:armory_allow_raw_query] != true
114
+
115
+ @telemetry.record(@strict ? :raw_query_allowed : :raw_query_run, context: context)
116
+ nil
117
+ end
118
+
98
119
  private
99
120
 
100
121
  # The signal says why, never which operation id or query: a client chooses those.
101
- def refuse(message, reason)
102
- @telemetry.record(:operation_refused, reason: reason)
122
+ def refuse(message, reason, context)
123
+ @telemetry.record(:operation_refused, context: context, reason: reason)
103
124
  [message, nil]
104
125
  end
105
126
 
@@ -109,6 +130,21 @@ module GraphQL
109
130
  end
110
131
  end
111
132
 
133
+ # A `GraphQL::Query` the app builds itself skips the schema's query class. The check sits where a query starts running,
134
+ # on both engines: an analyzer can be skipped by a variable error, swallowed by another analyzer's ExecutionError, or
135
+ # ignored with `validate: false`. A `run_partials` answers for the query it came from.
136
+ module BuiltQueries
137
+ def execute_query(query:)
138
+ built = query.respond_to?(:query) ? query.query : query
139
+ unless Lookup === built
140
+ refusal = built.schema.armory_persisted_queries.built_query(built.context)
141
+ raise GraphQL::ExecutionError.new(refusal, extensions: { "code" => REFUSAL_CODES.fetch(refusal) }) if refusal
142
+ end
143
+
144
+ super
145
+ end
146
+ end
147
+
112
148
  # Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
113
149
  module Guard
114
150
  UNSET = Object.new.freeze
@@ -126,7 +162,7 @@ module GraphQL
126
162
  end
127
163
 
128
164
  # The operation id a request sent, from Rack or Rails params: `operationId`, `extensions.operationId` or
129
- # Apollo's `extensions.persistedQuery.sha256Hash` (`extensions` may arrive as a JSON string on GET).
165
+ # Apollo's `extensions.persistedQuery.sha256Hash`, or Relay's `doc_id` (`extensions` may arrive as a JSON string on GET).
130
166
  # Answers nil when there is none, or when it is not a non-empty String.
131
167
  def self.operation_id(params)
132
168
  params = plain(params)
@@ -135,7 +171,7 @@ module GraphQL
135
171
  extensions = plain(extensions)
136
172
  persisted_query = plain(extensions["persistedQuery"])
137
173
 
138
- [params["operationId"], extensions["operationId"], persisted_query["sha256Hash"]].find do |candidate|
174
+ [params["operationId"], extensions["operationId"], persisted_query["sha256Hash"], params["doc_id"]].find do |candidate|
139
175
  candidate.is_a?(String) && !candidate.empty?
140
176
  end
141
177
  end
@@ -174,5 +210,8 @@ module GraphQL
174
210
  end
175
211
 
176
212
  require "graphql/armory/persisted_queries/manifest"
213
+ require "graphql/armory/persisted_queries/manifests"
177
214
  require "graphql/armory/persisted_queries/store"
215
+ require "graphql/armory/persisted_queries/redis_store"
216
+ require "graphql/armory/persisted_queries/publisher"
178
217
  require "graphql/armory/persisted_queries/controls"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ # Makes `rake graphql_armory:audit[YourSchema]` available in a Rails app. Rails is not a dependency: this loads only when it is there.
6
+ class Railtie < ::Rails::Railtie
7
+ rake_tasks { load "tasks/graphql_armory.rake" }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module RateLimits
6
+ # graphql-ruby discards an ExecutionError raised during analysis, drops what every analyzer answered, and runs the query.
7
+ # A client can choose that by field order when a `complexity:` lambda raises. So on such a raise each limiter still
8
+ # decides: a refusal already given stands, a limiter not asked yet is asked on its own, and a limiter that cannot answer
9
+ # refuses the query in hard mode and records `charged: false` in soft mode. Only the analysis of an execution counts: an
10
+ # analysis the app runs itself takes no slot and charges nothing here. It does not cover a trace that takes no modules
11
+ # from the schema (`context[:trace]`, or a class given to `trace_mode`).
12
+ module AnalysisGuard
13
+ ANSWERS = :limiter_answers
14
+ ASKED = :limiter_asked
15
+ ARMED = :limiter_guard_armed
16
+ NOT_CHARGED = "This query could not be charged to its rate limit."
17
+
18
+ # Runs a limiter, keeping per query and per limiter that it was asked and what it answered. A limiter that raised partway
19
+ # may have charged some of its keys, so it is never asked again.
20
+ def self.answer(query, limiter)
21
+ state = query.context.namespace(Points::NAMESPACE)
22
+ (state[ASKED] ||= {})[limiter] = true
23
+ (state[ANSWERS] ||= {})[limiter] = yield
24
+ end
25
+
26
+ def self.decide(query, error)
27
+ analyzers = query.analyzers.select { |analyzer| analyzer.respond_to?(:armory_limiter) }
28
+ state = query.context.namespace(Points::NAMESPACE)
29
+ answers = (state[ANSWERS] ||= {})
30
+ asked = (state[ASKED] ||= {})
31
+ analyzers.reject { |analyzer| asked.key?(analyzer.armory_limiter) }.each { |analyzer| GraphQL::Analysis.analyze_query(query, [analyzer]) }
32
+
33
+ silent = analyzers.map(&:armory_limiter).reject { |limiter| answers.key?(limiter) || exempt?(limiter, query) }
34
+ silent.each { |limiter| query.context[limiter.class::CONTEXT_KEY] = { limit: nil, soft: limiter.soft?, limited: !limiter.soft?, charged: false } }
35
+
36
+ refusals = answers.values.compact
37
+ return refusals unless refusals.empty?
38
+ return [] if silent.all?(&:soft?)
39
+
40
+ [error.is_a?(GraphQL::AnalysisError) ? error : GraphQL::AnalysisError.new(NOT_CHARGED, extensions: { "code" => "RATE_LIMIT_NOT_CHARGED" })]
41
+ end
42
+
43
+ # A limit that cannot be asked for does not exempt anyone.
44
+ def self.exempt?(limiter, query)
45
+ limiter.exempt?(query)
46
+ rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError
47
+ false
48
+ end
49
+ private_class_method :exempt?
50
+
51
+ def analyze_multiplex(multiplex:)
52
+ multiplex.queries.each { |query| query.context.namespace(Points::NAMESPACE)[ARMED] = true }
53
+ super
54
+ end
55
+
56
+ # The first analysis of each query in an execution. Any other, nested in it or run by the app, passes through, and
57
+ # each analyzer is asked apart, so one that raises leaves the others asked.
58
+ def analyze_query(query:)
59
+ return super unless query.context.namespace(Points::NAMESPACE).delete(ARMED)
60
+
61
+ begin
62
+ super
63
+ rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => error
64
+ decided(query, error)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def decided(query, error)
71
+ refusals = AnalysisGuard.decide(query, error)
72
+ raise error if refusals.empty?
73
+
74
+ refusals
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -18,20 +18,24 @@ module GraphQL
18
18
  CONTEXT_KEY = :rate_limit_concurrency
19
19
  NAMESPACE = :graphql_armory
20
20
 
21
- def self.use(schema, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
22
- concurrency = build(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
23
- schema.query_analyzer(Class.new(Analyzer) { define_method(:concurrency) { concurrency } })
21
+ def self.use(schema, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil, sinks: [])
22
+ concurrency = build(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks)
23
+ schema.query_analyzer(Class.new(Analyzer) do
24
+ define_method(:concurrency) { concurrency }
25
+ define_singleton_method(:armory_limiter) { concurrency }
26
+ end)
24
27
  schema.trace_with(Release)
28
+ schema.trace_with(AnalysisGuard)
25
29
  end
26
30
 
27
31
  # Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
28
- def self.build(store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
29
- new(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
32
+ def self.build(store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil, sinks: [])
33
+ new(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks)
30
34
  end
31
35
 
32
36
  # `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
33
37
  # configuration lets a query through either.
34
- def initialize(store: Store::Null.new, limit: 1, stale_after: 60, soft: false, key: nil, on_store_error: nil)
38
+ def initialize(store: Store::Null.new, limit: 1, stale_after: 60, soft: false, key: nil, on_store_error: nil, sinks: [])
35
39
  unless Settings.store_answers?(store, acquire: %i[token limit ttl force], release: %i[token])
36
40
  raise ConfigurationError, "store: must answer acquire(key, token:, limit:, ttl:, force:) and release(key, token:), like a MemoryStore"
37
41
  end
@@ -41,7 +45,16 @@ module GraphQL
41
45
  @stale_after = Settings.seconds(:stale_after, stale_after)
42
46
  @soft = Settings.wired_soft(soft, store)
43
47
  @key = Settings.key_reader(key)
44
- @on_store_error = Settings.store_error_handler(on_store_error)
48
+ @on_store_error = Settings.store_error_handler(on_store_error, limiter: :concurrency, sinks: sinks)
49
+ end
50
+
51
+ def soft?
52
+ @soft
53
+ end
54
+
55
+ # Whether this query's client has no limit to charge.
56
+ def exempt?(query)
57
+ Settings.exempt?(@key, @limit, query)
45
58
  end
46
59
 
47
60
  # Takes a slot for the query: the refusal when none is free, or nil to let it run.
@@ -51,6 +64,8 @@ module GraphQL
51
64
  end
52
65
 
53
66
  key = Settings.key_for(@key, query)
67
+ return call_several(query, key) if key.is_a?(Array)
68
+
54
69
  limit = Settings.limit_for(@limit, key, query)
55
70
  if limit.nil?
56
71
  record(query, key: key, limit: nil, active: nil, limited: false)
@@ -67,7 +82,7 @@ module GraphQL
67
82
  acquired, active = Settings.checked(answer, :acquire, "the Integer slots held")
68
83
 
69
84
  # Per query, not in the context hash: queries of a multiplex may share one hash.
70
- query.context.namespace(NAMESPACE)[:concurrency_slot] = [self, key, token] if acquired || @soft
85
+ slots_of(query) << [self, key, token] if acquired || @soft
71
86
  record(query, key: key, limit: limit, active: active, limited: !acquired)
72
87
  refusal(limit, active) unless acquired || @soft
73
88
  end
@@ -78,10 +93,73 @@ module GraphQL
78
93
  @on_store_error.call(error, query)
79
94
  end
80
95
 
96
+ # Gives every slot back, even if one release raises (an on_store_error that raises); the first such error is
97
+ # raised afterwards.
98
+ def self.release_all(query, slots)
99
+ failure = nil
100
+ slots.each do |concurrency, key, token|
101
+ concurrency.release(query, key, token)
102
+ rescue StandardError => error
103
+ failure ||= error
104
+ end
105
+ raise failure if failure
106
+ end
107
+
81
108
  private
82
109
 
110
+ def slots_of(query)
111
+ query.context.namespace(NAMESPACE)[:concurrency_slots] ||= []
112
+ end
113
+
114
+ # Takes a slot per key, in order, and stops at the first full key, so a refused client holds a shared key as little
115
+ # as it can. A refusal gives back what this query took here, at once: a multiplex can run on for long. Each slot is
116
+ # listed as it is taken, so a raise later on still lets `Release` give it back. A key whose store fails is left out,
117
+ # and the others still decide.
118
+ def call_several(query, keys)
119
+ limits = Settings.limits_for(@limit, keys, query)
120
+ if limits.empty?
121
+ record(query, key: keys.first, limit: nil, active: nil, limited: false, keys: [])
122
+ return nil
123
+ end
124
+
125
+ taken = []
126
+ held = []
127
+ limits.each do |key, limit|
128
+ token = SecureRandom.hex(16)
129
+ answer = begin
130
+ @store.acquire(key, token: token, limit: limit, ttl: @stale_after, force: @soft)
131
+ rescue StandardError => error
132
+ @on_store_error.call(error, query)
133
+ next
134
+ end
135
+ acquired, active = Settings.checked(answer, :acquire, "the Integer slots held")
136
+ if acquired || @soft
137
+ slot = [self, key, token]
138
+ taken << slot
139
+ slots_of(query) << slot
140
+ end
141
+ held << { key: key, limit: limit, active: active, acquired: acquired }
142
+ break unless acquired || @soft
143
+ end
144
+ return nil if held.empty?
145
+
146
+ settle_several(query, held, taken)
147
+ end
148
+
149
+ def settle_several(query, held, taken)
150
+ full = held.find { |slot| !slot[:acquired] }
151
+ binding = full || held.max_by { |slot| slot[:active] / slot[:limit].to_f }
152
+ record(query, key: binding[:key], limit: binding[:limit], active: binding[:active], limited: !full.nil?,
153
+ keys: held.map { |slot| slot.slice(:key, :limit, :active) })
154
+ return unless full && !@soft
155
+
156
+ slots_of(query).reject! { |slot| taken.include?(slot) }
157
+ self.class.release_all(query, taken)
158
+ refusal(full[:limit], full[:active])
159
+ end
160
+
83
161
  def record(query, **info)
84
- query.context[CONTEXT_KEY] = { **info.slice(:key, :limit, :active), soft: @soft, limited: info[:limited] }
162
+ query.context[CONTEXT_KEY] = { **info.slice(:key, :limit, :active), soft: @soft, limited: info[:limited], **info.slice(:keys) }
85
163
  end
86
164
 
87
165
  def refusal(limit, active)
@@ -94,7 +172,7 @@ module GraphQL
94
172
  # Takes the slot during analysis, before anything runs.
95
173
  class Analyzer < GraphQL::Analysis::Analyzer
96
174
  def result
97
- concurrency.(query)
175
+ AnalysisGuard.answer(query, concurrency) { concurrency.(query) }
98
176
  end
99
177
  end
100
178
 
@@ -108,12 +186,11 @@ module GraphQL
108
186
  # error is raised afterwards.
109
187
  failure = nil
110
188
  multiplex.queries.each do |query|
111
- slots = query.context.namespace(NAMESPACE)
112
- concurrency, key, token = slots.delete(:concurrency_slot)
113
- next unless concurrency
189
+ slots = query.context.namespace(NAMESPACE).delete(:concurrency_slots)
190
+ next unless slots
114
191
 
115
192
  begin
116
- concurrency.release(query, key, token)
193
+ Concurrency.release_all(query, slots)
117
194
  rescue StandardError => error
118
195
  failure ||= error
119
196
  end
@@ -16,9 +16,13 @@ module GraphQL
16
16
  CONTEXT_KEY = :rate_limit_points
17
17
  NAMESPACE = :graphql_armory
18
18
 
19
- def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
20
- points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
21
- schema.query_analyzer(Class.new(Analyzer) { define_method(:points) { points } })
19
+ def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false)
20
+ points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions)
21
+ schema.query_analyzer(Class.new(Analyzer) do
22
+ define_method(:points) { points }
23
+ define_singleton_method(:armory_limiter) { points }
24
+ end)
25
+ schema.trace_with(AnalysisGuard)
22
26
  end
23
27
 
24
28
  # HTTP headers for the budget a result recorded: `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
@@ -35,13 +39,13 @@ module GraphQL
35
39
  end
36
40
 
37
41
  # Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
38
- def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
39
- new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
42
+ def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false)
43
+ new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions)
40
44
  end
41
45
 
42
46
  # `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
43
47
  # configuration lets a query through either.
44
- def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, extensions: false)
48
+ def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, sinks: [], extensions: false)
45
49
  unless Settings.store_answers?(store, take: %i[cost limit window force])
46
50
  raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
47
51
  end
@@ -52,13 +56,24 @@ module GraphQL
52
56
  @window = Settings.seconds(:window, window)
53
57
  @soft = Settings.wired_soft(soft, store)
54
58
  @key = Settings.key_reader(key)
55
- @on_store_error = Settings.store_error_handler(on_store_error)
59
+ @on_store_error = Settings.store_error_handler(on_store_error, limiter: :points, sinks: sinks)
56
60
  @extensions = extensions
57
61
  end
58
62
 
63
+ def soft?
64
+ @soft
65
+ end
66
+
67
+ # Whether this query's client has no limit to charge.
68
+ def exempt?(query)
69
+ Settings.exempt?(@key, @limit, query)
70
+ end
71
+
59
72
  # The refusal for a query that costs `cost`, or nil to let it run.
60
73
  def call(query, cost)
61
74
  key = Settings.key_for(@key, query)
75
+ return call_several(query, key, cost) if key.is_a?(Array)
76
+
62
77
  limit = Settings.limit_for(@limit, key, query)
63
78
  if limit.nil?
64
79
  record(query, key: key, cost: cost, limit: nil, remaining: nil, limited: false)
@@ -76,19 +91,70 @@ module GraphQL
76
91
  taken, remaining = Settings.checked(answer, :take, "the Integer points left")
77
92
 
78
93
  info = record(query, key: key, cost: cost, limit: limit, remaining: remaining, limited: !taken)
79
- if @extensions
80
- query.context.response_extensions["rateLimit"] =
81
- { "cost" => cost, "limit" => limit, "remaining" => remaining, "resetIn" => self.class.reset_in(info) }
82
- end
94
+ announce(query, info, cost) if @extensions
83
95
  refusal(cost, limit, remaining) unless taken || @soft
84
96
  end
85
97
 
86
98
  private
87
99
 
100
+ # Reads every balance first, so a query one key refuses leaves the others as they were, then charges each key.
101
+ # Two queries can race between the two steps: then the refused one leaves the other keys charged. A key whose store
102
+ # fails is left out, and the others still decide: an attacker who can pick a dead shard must not cancel a refusal.
103
+ def call_several(query, keys, cost)
104
+ limits = Settings.limits_for(@limit, keys, query)
105
+ if limits.empty?
106
+ record(query, key: keys.first, cost: cost, limit: nil, remaining: nil, limited: false, keys: [])
107
+ return nil
108
+ end
109
+
110
+ read = takes(query, limits, 0, false) unless @soft
111
+ budgets = if read&.any? { |budget| budget[:remaining] < cost }
112
+ read
113
+ else
114
+ takes(query, read ? read.to_h { |budget| budget.values_at(:key, :limit) } : limits, cost, @soft, charging: true)
115
+ end
116
+ if budgets.empty?
117
+ query.context[CONTEXT_KEY] = nil
118
+ return nil
119
+ end
120
+
121
+ refused = budgets.select { |budget| budget.fetch(:taken) { budget[:remaining] >= cost } == false }
122
+ binding = binding_budget(refused, cost) || budgets.min_by { |budget| budget[:remaining] }
123
+ limited = !refused.empty?
124
+ info = record(query, key: binding[:key], cost: cost, limit: binding[:limit], remaining: binding[:remaining], limited: limited,
125
+ keys: budgets.map { |budget| budget.slice(:key, :limit, :remaining) })
126
+ announce(query, info, cost) if @extensions
127
+ refusal(cost, binding[:limit], binding[:remaining]) if limited && !@soft
128
+ end
129
+
130
+ # What the store answers for each key, without the keys whose store failed.
131
+ def takes(query, limits, cost, force, charging: false)
132
+ limits.filter_map do |key, limit|
133
+ answer = begin
134
+ @store.take(key, cost: cost, limit: limit, window: @window, force: force)
135
+ rescue StandardError => error
136
+ @on_store_error.call(error, query)
137
+ next
138
+ end
139
+ taken, remaining = Settings.checked(answer, :take, "the Integer points left")
140
+ { key: key, limit: limit, remaining: remaining, **(charging ? { taken: taken } : {}) }
141
+ end
142
+ end
143
+
144
+ # What the client is told: a key that can never pay, otherwise the longest wait.
145
+ def binding_budget(refused, cost)
146
+ refused.find { |budget| cost > budget[:limit] } || refused.max_by { |budget| (cost - budget[:remaining]) / budget[:limit].to_f }
147
+ end
148
+
149
+ def announce(query, info, cost)
150
+ query.context.response_extensions["rateLimit"] =
151
+ { "cost" => cost, "limit" => info[:limit], "remaining" => info[:remaining], "resetIn" => self.class.reset_in(info) }
152
+ end
153
+
88
154
  # In the context hash for instrumentation, and per query for `headers`: queries of a multiplex may share one
89
155
  # context hash, where the last query's record wins.
90
156
  def record(query, **info)
91
- info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited] }
157
+ info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited], **info.slice(:keys) }
92
158
  query.context.namespace(NAMESPACE)[:points] = info
93
159
  query.context[CONTEXT_KEY] = info
94
160
  end
@@ -110,14 +176,25 @@ module GraphQL
110
176
 
111
177
  # graphql-ruby's own complexity analysis, then the charge.
112
178
  class Analyzer < GraphQL::Analysis::QueryComplexity
179
+ # graphql-ruby's words when a connection has no `first:`, `last:` or page size. A client can send that, so it answers
180
+ # an error for the client and not a server error. Nothing is charged, and any other error while scoring still raises.
181
+ UNSIZED_CONNECTION = "Can't calculate complexity for "
182
+
113
183
  def result
114
- cost = super
184
+ cost = begin
185
+ super
186
+ rescue GraphQL::Error => error
187
+ raise unless error.instance_of?(GraphQL::Error) && error.message.start_with?(UNSIZED_CONNECTION)
188
+
189
+ query.context[CONTEXT_KEY] = nil
190
+ return AnalysisGuard.answer(query, points) { GraphQL::AnalysisError.new("This connection needs first: or last:", extensions: { "code" => "PAGE_SIZE_REQUIRED" }) }
191
+ end
115
192
  unless cost.is_a?(Numeric) && cost.finite? && !cost.negative?
116
193
  raise ConfigurationError, "the query complexity is #{cost.inspect}: field complexities must be numbers of zero or more"
117
194
  end
118
195
 
119
196
  # Fractional complexities (`complexity: 0.5`) are charged whole points, rounded up.
120
- points.(query, cost.ceil)
197
+ AnalysisGuard.answer(query, points) { points.(query, cost.ceil) }
121
198
  end
122
199
  end
123
200
  end