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
|
@@ -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: [
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
113
|
-
next unless concurrency
|
|
189
|
+
slots = query.context.namespace(NAMESPACE).delete(:concurrency_slots)
|
|
190
|
+
next unless slots
|
|
114
191
|
|
|
115
192
|
begin
|
|
116
|
-
|
|
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)
|
|
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 =
|
|
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
|