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
|
@@ -17,9 +17,17 @@ module GraphQL
|
|
|
17
17
|
class Time
|
|
18
18
|
CONTEXT_KEY = :rate_limit_time
|
|
19
19
|
NAMESPACE = :graphql_armory
|
|
20
|
+
BUCKET_PREFIX = "time:"
|
|
20
21
|
|
|
21
|
-
def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, clock: Clock.monotonic, extensions: false)
|
|
22
|
-
build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, clock: clock, extensions: extensions).install(schema)
|
|
22
|
+
def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], clock: Clock.monotonic, extensions: false)
|
|
23
|
+
build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, clock: clock, extensions: extensions).install(schema)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Where a client's milliseconds live in the store: apart from its points, so Points and Time can share a store
|
|
27
|
+
# and a key. Read a balance with this, not with the client's key. In the key's encoding: interpolating a UTF-16
|
|
28
|
+
# key would raise inside the limiter's rescue, which fails open.
|
|
29
|
+
def self.bucket_key(key)
|
|
30
|
+
BUCKET_PREFIX.encode(key.encoding) + key
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
# HTTP headers for the budget a result recorded: `RateLimit-Time-Limit` and `RateLimit-Time-Remaining` in
|
|
@@ -37,13 +45,13 @@ module GraphQL
|
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
# Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
|
|
40
|
-
def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, clock: Clock.monotonic, extensions: false)
|
|
41
|
-
new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, clock: clock, extensions: extensions)
|
|
48
|
+
def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], clock: Clock.monotonic, extensions: false)
|
|
49
|
+
new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, clock: clock, extensions: extensions)
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
# `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
|
|
45
53
|
# configuration lets a query through either.
|
|
46
|
-
def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, clock: Clock.monotonic, extensions: false)
|
|
54
|
+
def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, sinks: [], clock: Clock.monotonic, extensions: false)
|
|
47
55
|
unless Settings.store_answers?(store, take: %i[cost limit window force])
|
|
48
56
|
raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
|
|
49
57
|
end
|
|
@@ -53,7 +61,7 @@ module GraphQL
|
|
|
53
61
|
@window = Settings.seconds(:window, window)
|
|
54
62
|
@soft = Settings.wired_soft(soft, store)
|
|
55
63
|
@key = Settings.key_reader(key)
|
|
56
|
-
@on_store_error = Settings.store_error_handler(on_store_error)
|
|
64
|
+
@on_store_error = Settings.store_error_handler(on_store_error, limiter: :time, sinks: sinks)
|
|
57
65
|
@clock = Settings.clock(clock)
|
|
58
66
|
now # a clock that answers anything but a finite number fails here, not by charging nothing
|
|
59
67
|
@extensions = Settings.extensions(extensions)
|
|
@@ -63,8 +71,21 @@ module GraphQL
|
|
|
63
71
|
# Adds the analyzer and the trace to the schema.
|
|
64
72
|
def install(schema)
|
|
65
73
|
time = self
|
|
66
|
-
schema.query_analyzer(Class.new(Analyzer)
|
|
74
|
+
schema.query_analyzer(Class.new(Analyzer) do
|
|
75
|
+
define_method(:time) { time }
|
|
76
|
+
define_singleton_method(:armory_limiter) { time }
|
|
77
|
+
end)
|
|
67
78
|
schema.trace_with(@trace)
|
|
79
|
+
schema.trace_with(AnalysisGuard)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def soft?
|
|
83
|
+
@soft
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether this query's client has no limit to charge.
|
|
87
|
+
def exempt?(query)
|
|
88
|
+
Settings.exempt?(@key, @limit, query)
|
|
68
89
|
end
|
|
69
90
|
|
|
70
91
|
# The refusal for a client with no milliseconds left, or nil to let the operation run (and be charged).
|
|
@@ -74,6 +95,8 @@ module GraphQL
|
|
|
74
95
|
end
|
|
75
96
|
|
|
76
97
|
key = Settings.key_for(@key, query)
|
|
98
|
+
return call_several(query, key) if key.is_a?(Array)
|
|
99
|
+
|
|
77
100
|
limit = Settings.limit_for(@limit, key, query)
|
|
78
101
|
if limit.nil?
|
|
79
102
|
record(query, key: key, limit: nil, remaining: nil, limited: false)
|
|
@@ -81,7 +104,7 @@ module GraphQL
|
|
|
81
104
|
end
|
|
82
105
|
|
|
83
106
|
answer = begin
|
|
84
|
-
@store.take(key, cost: 0, limit: limit, window: @window, force: false)
|
|
107
|
+
@store.take(self.class.bucket_key(key), cost: 0, limit: limit, window: @window, force: false)
|
|
85
108
|
rescue StandardError => error
|
|
86
109
|
# Fail open, and still measure: the charge may find the store back.
|
|
87
110
|
query.context[CONTEXT_KEY] = nil
|
|
@@ -135,27 +158,92 @@ module GraphQL
|
|
|
135
158
|
|
|
136
159
|
private
|
|
137
160
|
|
|
161
|
+
# Each key is charged on its own: a store failure on one, or a hook that raises for it, leaves the others charged. The
|
|
162
|
+
# first error a hook raises is raised afterwards.
|
|
138
163
|
def charge_one(query, debt, cost)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
failure = nil
|
|
165
|
+
charged = (debt[:others] || {}).to_h do |key, limit|
|
|
166
|
+
[key, charge_key(key, limit, cost)]
|
|
167
|
+
rescue StandardError => error
|
|
168
|
+
failure ||= heard(query, error)
|
|
169
|
+
[key, nil]
|
|
170
|
+
end
|
|
171
|
+
begin
|
|
172
|
+
settle(query, debt, cost, charge_key(debt[:key], debt[:limit], cost), charged)
|
|
173
|
+
rescue StandardError => error
|
|
174
|
+
failure ||= heard(query, error)
|
|
175
|
+
end
|
|
176
|
+
raise failure if failure
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def charge_key(key, limit, cost)
|
|
180
|
+
answer = @store.take(self.class.bucket_key(key), cost: cost, limit: limit, window: @window, force: true)
|
|
181
|
+
Settings.checked(answer, :take, "the Integer milliseconds left").last
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# What the hook raised for this error, if it did.
|
|
185
|
+
def heard(query, error)
|
|
143
186
|
@on_store_error.call(error, query)
|
|
187
|
+
nil
|
|
188
|
+
rescue StandardError => raised
|
|
189
|
+
raised
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Reads every balance. A refusal charges none, as with one key; otherwise every key owes the whole time. A key whose
|
|
193
|
+
# store fails at the read still owes it, and the others still decide.
|
|
194
|
+
def call_several(query, keys)
|
|
195
|
+
limits = Settings.limits_for(@limit, keys, query)
|
|
196
|
+
if limits.empty?
|
|
197
|
+
record(query, key: keys.first, limit: nil, remaining: nil, limited: false, keys: [])
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
budgets = limits.filter_map do |key, limit|
|
|
202
|
+
answer = begin
|
|
203
|
+
@store.take(self.class.bucket_key(key), cost: 0, limit: limit, window: @window, force: false)
|
|
204
|
+
rescue StandardError => error
|
|
205
|
+
@on_store_error.call(error, query)
|
|
206
|
+
next
|
|
207
|
+
end
|
|
208
|
+
{ key: key, limit: limit, remaining: Settings.checked(answer, :take, "the Integer milliseconds left").last }
|
|
209
|
+
end
|
|
210
|
+
if budgets.empty?
|
|
211
|
+
query.context[CONTEXT_KEY] = nil
|
|
212
|
+
first, *rest = limits.to_a
|
|
213
|
+
owe(query, first[0], first[1], others: rest.to_h)
|
|
214
|
+
return nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
limited = budgets.any? { |budget| budget[:remaining].zero? }
|
|
218
|
+
binding = budgets.select { |budget| budget[:remaining].zero? }.min_by { |budget| budget[:limit] } || budgets.min_by { |budget| budget[:remaining] }
|
|
219
|
+
info = record(query, key: binding[:key], limit: binding[:limit], remaining: binding[:remaining], limited: limited, keys: budgets)
|
|
220
|
+
extension = announce(query, info) if @extensions
|
|
221
|
+
return refusal(binding[:limit]) if limited && !@soft
|
|
222
|
+
|
|
223
|
+
owe(query, binding[:key], binding[:limit], info, extension, others: limits.reject { |key, _limit| key == binding[:key] })
|
|
224
|
+
nil
|
|
144
225
|
end
|
|
145
226
|
|
|
146
227
|
# What the client has left once the charge is in, in the record and in the extensions announced earlier.
|
|
147
|
-
def settle(query, debt, cost, remaining)
|
|
228
|
+
def settle(query, debt, cost, remaining, others = {})
|
|
148
229
|
info = (debt[:info] || base_record(key: debt[:key], limit: debt[:limit], limited: false)).merge(remaining: remaining, elapsed: cost)
|
|
230
|
+
if info[:keys]
|
|
231
|
+
current = others.compact.merge(debt[:key] => remaining)
|
|
232
|
+
info[:keys] = info[:keys].map { |budget| budget.merge(remaining: current.fetch(budget[:key], budget[:remaining])) }
|
|
233
|
+
# Keys with different limits refill at different rates during the run, so the binding one may have changed.
|
|
234
|
+
info = info.merge(info[:keys].min_by { |budget| budget[:remaining] })
|
|
235
|
+
remaining = info[:remaining]
|
|
236
|
+
end
|
|
149
237
|
query.context.namespace(NAMESPACE)[:time] = info
|
|
150
238
|
query.context[CONTEXT_KEY] = info
|
|
151
|
-
debt[:extension]&.merge!("remaining" => remaining, "elapsed" => cost, "resetIn" => Points.reset_in(info))
|
|
239
|
+
debt[:extension]&.merge!("limit" => info[:limit], "remaining" => remaining, "elapsed" => cost, "resetIn" => Points.reset_in(info))
|
|
152
240
|
end
|
|
153
241
|
|
|
154
242
|
# Per query and per limiter, not in the context hash: queries of a multiplex may share one hash, and a
|
|
155
243
|
# schema may use several limiters.
|
|
156
|
-
def owe(query, key, limit, info = nil, extension = nil)
|
|
244
|
+
def owe(query, key, limit, info = nil, extension = nil, others: {})
|
|
157
245
|
charges = (query.context.namespace(NAMESPACE)[:time_charges] ||= {})
|
|
158
|
-
charges[self] = { key: key, limit: limit, info: info, extension: extension }
|
|
246
|
+
charges[self] = { key: key, limit: limit, info: info, extension: extension, others: others }
|
|
159
247
|
end
|
|
160
248
|
|
|
161
249
|
def record(query, **info)
|
|
@@ -165,8 +253,8 @@ module GraphQL
|
|
|
165
253
|
end
|
|
166
254
|
|
|
167
255
|
# `elapsed` is nil until the operation ends and is charged; it stays nil for a refused one.
|
|
168
|
-
def base_record(key:, limit:, limited:, remaining: nil)
|
|
169
|
-
{ key: key, limit: limit, remaining: remaining, elapsed: nil, window: @window, soft: @soft, limited: limited }
|
|
256
|
+
def base_record(key:, limit:, limited:, remaining: nil, keys: nil)
|
|
257
|
+
{ key: key, limit: limit, remaining: remaining, elapsed: nil, window: @window, soft: @soft, limited: limited, **(keys ? { keys: keys } : {}) }
|
|
170
258
|
end
|
|
171
259
|
|
|
172
260
|
# The budget before the run; the charge completes the same hash, which the response already holds.
|
|
@@ -200,7 +288,7 @@ module GraphQL
|
|
|
200
288
|
# Asks for the balance during analysis, before anything runs.
|
|
201
289
|
class Analyzer < GraphQL::Analysis::Analyzer
|
|
202
290
|
def result
|
|
203
|
-
time.(query)
|
|
291
|
+
AnalysisGuard.answer(query, time) { time.(query) }
|
|
204
292
|
end
|
|
205
293
|
end
|
|
206
294
|
end
|
|
@@ -9,6 +9,8 @@ module GraphQL
|
|
|
9
9
|
class ConfigurationError < Armory::Error; end
|
|
10
10
|
|
|
11
11
|
KEY = :rate_limit_key
|
|
12
|
+
# Each key costs store round trips on every query, so a list cannot grow with what a client sends.
|
|
13
|
+
MAX_KEYS = 5
|
|
12
14
|
|
|
13
15
|
# The settings every limiter shares, checked at boot.
|
|
14
16
|
module Settings
|
|
@@ -21,11 +23,28 @@ module GraphQL
|
|
|
21
23
|
key
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# A String, or a list of them for a query that draws from several buckets.
|
|
24
27
|
def key_for(reader, query)
|
|
25
28
|
key = reader.call(query)
|
|
26
29
|
return key if key.is_a?(String) && !key.empty?
|
|
30
|
+
return key if several_keys?(key)
|
|
27
31
|
|
|
28
|
-
raise ConfigurationError, "no client key for this query: put a non-empty String in context[:#{KEY}], or give the limiter a key: callable"
|
|
32
|
+
raise ConfigurationError, "no client key for this query: put a non-empty String, or a list of up to #{MAX_KEYS} different ones, in context[:#{KEY}], or give the limiter a key: callable"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exempt?(reader, limit, query)
|
|
36
|
+
key = key_for(reader, query)
|
|
37
|
+
key.is_a?(Array) ? limits_for(limit, key, query).empty? : limit_for(limit, key, query).nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def several_keys?(keys)
|
|
41
|
+
keys.is_a?(Array) && keys.size.between?(1, MAX_KEYS) && keys.all? { |key| key.is_a?(String) && !key.empty? } && keys.map(&:b).uniq.size == keys.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The limit of each key, with the keys that have none left out. All of them are asked before any store call, so
|
|
45
|
+
# a limit that raises leaves nothing charged or held.
|
|
46
|
+
def limits_for(limit, keys, query)
|
|
47
|
+
keys.to_h { |key| [key, limit_for(limit, key, query)] }.compact
|
|
29
48
|
end
|
|
30
49
|
|
|
31
50
|
def soft(value)
|
|
@@ -110,11 +129,15 @@ module GraphQL
|
|
|
110
129
|
value
|
|
111
130
|
end
|
|
112
131
|
|
|
113
|
-
def store_error_handler(handler)
|
|
114
|
-
|
|
115
|
-
raise ConfigurationError, "on_store_error: must be a callable that takes the error and the query" unless handler.respond_to?(:call)
|
|
132
|
+
def store_error_handler(handler, limiter: nil, sinks: [])
|
|
133
|
+
raise ConfigurationError, "on_store_error: must be a callable that takes the error and the query" unless handler.nil? || handler.respond_to?(:call)
|
|
116
134
|
|
|
117
|
-
|
|
135
|
+
telemetry = Telemetry.build(sinks: sinks)
|
|
136
|
+
handler ||= ->(error, _query) { warn("graphql-armory rate limit store failed, query let through: #{error.class}") }
|
|
137
|
+
lambda do |error, query|
|
|
138
|
+
telemetry.record(:rate_limit_store_failed, context: query.context, limiter: limiter, error: error.class.to_s)
|
|
139
|
+
handler.call(error, query)
|
|
140
|
+
end
|
|
118
141
|
end
|
|
119
142
|
end
|
|
120
143
|
end
|
|
@@ -125,6 +148,7 @@ require "graphql/armory/rate_limits/memory_store"
|
|
|
125
148
|
require "graphql/armory/rate_limits/store"
|
|
126
149
|
require "graphql/armory/rate_limits/clock"
|
|
127
150
|
require "graphql/armory/rate_limits/redis_store"
|
|
151
|
+
require "graphql/armory/rate_limits/analysis_guard"
|
|
128
152
|
require "graphql/armory/rate_limits/points"
|
|
129
153
|
require "graphql/armory/rate_limits/concurrency"
|
|
130
154
|
require "graphql/armory/rate_limits/time"
|
|
@@ -4,16 +4,34 @@ module GraphQL
|
|
|
4
4
|
module Armory
|
|
5
5
|
# Records what a module decided (a request allowed, an ID refused, an operation run) and hands it to its sinks.
|
|
6
6
|
# A sink is anything that responds to `call(signal, data)`: a lambda that logs, a metrics client, or
|
|
7
|
-
# `Telemetry::Notifications`.
|
|
7
|
+
# `Telemetry::Notifications`. A sink that requires a third argument also receives the query's context, to read who
|
|
8
|
+
# asked from it; the context never goes in `data`. Never put record attributes, IDs, keys or queries in `data`: a client chooses
|
|
8
9
|
# those, and they would end up in the app's logs.
|
|
9
10
|
class Telemetry
|
|
10
11
|
class ConfigurationError < Armory::Error; end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
# The class alone: an error's message may quote what the sink was sending, or its credentials.
|
|
14
|
+
DEFAULT_ON_SINK_ERROR = ->(error, signal) { warn("graphql-armory sink failed on #{signal}, request continues: #{error.class}") }
|
|
15
|
+
|
|
16
|
+
def self.build(sinks: [], on_sink_error: nil)
|
|
17
|
+
new(sinks: sinks, on_sink_error: on_sink_error)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# What a sink reads the context through: it decides nothing, so it cannot change who the query runs as. An object in
|
|
21
|
+
# the context is the app's own, and stays as it is. `inspect` stays out of it, so a sink that logs its arguments logs no token.
|
|
22
|
+
class ContextView
|
|
23
|
+
def initialize(context)
|
|
24
|
+
@context = context
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def [](key) = @context[key]
|
|
28
|
+
def fetch(key, *default, &block) = @context.fetch(key, *default, &block)
|
|
29
|
+
def dig(key, *rest) = @context.dig(key, *rest)
|
|
30
|
+
def key?(key) = @context.key?(key)
|
|
31
|
+
def inspect = "#<#{self.class.name}>"
|
|
14
32
|
end
|
|
15
33
|
|
|
16
|
-
def initialize(sinks: [])
|
|
34
|
+
def initialize(sinks: [], on_sink_error: nil)
|
|
17
35
|
# Messages name a class, never `inspect` a value: a metrics client holds its API key.
|
|
18
36
|
raise ConfigurationError, "sinks: must be an Array of callables taking (signal, data), got #{sinks.class}" unless sinks.is_a?(Array)
|
|
19
37
|
|
|
@@ -24,18 +42,56 @@ module GraphQL
|
|
|
24
42
|
raise ConfigurationError, "sink #{sink.is_a?(Module) ? sink.name : sink.class} is not available: the library it reports to is not loaded"
|
|
25
43
|
end
|
|
26
44
|
|
|
27
|
-
|
|
45
|
+
raise ConfigurationError, "on_sink_error: must be a callable taking the error and the signal, found a #{on_sink_error.class}" unless on_sink_error.nil? || on_sink_error.respond_to?(:call)
|
|
46
|
+
|
|
47
|
+
@sinks = sinks.map { |sink| [sink, takes_context?(sink)] }.freeze
|
|
48
|
+
@on_sink_error = on_sink_error || DEFAULT_ON_SINK_ERROR
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether anything hears what is recorded.
|
|
52
|
+
def listening?
|
|
53
|
+
!@sinks.empty?
|
|
28
54
|
end
|
|
29
55
|
|
|
30
56
|
# Every sink gets the same frozen data, so one sink (or a subscriber) cannot change what the next one sees.
|
|
31
|
-
def record(signal, **data)
|
|
57
|
+
def record(signal, context: nil, **data)
|
|
32
58
|
return nil if @sinks.empty?
|
|
33
59
|
|
|
34
60
|
data = data.to_h { |key, value| [key, value.is_a?(String) ? value.dup.freeze : value] }.freeze
|
|
35
|
-
@sinks.
|
|
61
|
+
view = ContextView.new(context || {}) if @sinks.any? { |_sink, takes_context| takes_context }
|
|
62
|
+
@sinks.each do |sink, takes_context|
|
|
63
|
+
takes_context ? sink.call(signal, data, view) : sink.call(signal, data)
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
@on_sink_error.call(error, signal)
|
|
66
|
+
end
|
|
36
67
|
nil
|
|
37
68
|
end
|
|
38
69
|
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Only a sink that requires it: one taking `*arguments` or a defaulted third may log whatever it gets. A sink whose
|
|
73
|
+
# `call` cannot be read (a BasicObject, one behind method_missing) takes two.
|
|
74
|
+
def takes_context?(sink)
|
|
75
|
+
callable = Proc === sink || Method === sink ? sink : ::Kernel.instance_method(:method).bind_call(sink, :call)
|
|
76
|
+
return callable.arity == 3 if Proc === callable && !callable.lambda?
|
|
77
|
+
|
|
78
|
+
parameters = callable.parameters
|
|
79
|
+
refuse_uncallable(sink, parameters)
|
|
80
|
+
parameters.count { |type, _| type == :req } == 3
|
|
81
|
+
rescue NameError, TypeError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# It would raise on every signal, and with sinks that no longer fail the request nobody would notice.
|
|
86
|
+
def refuse_uncallable(sink, parameters)
|
|
87
|
+
required = parameters.count { |type, _| type == :req }
|
|
88
|
+
optional = parameters.count { |type, _| type == :opt }
|
|
89
|
+
rest = parameters.any? { |type, _| type == :rest }
|
|
90
|
+
return unless required > 3 || parameters.any? { |type, _| type == :keyreq } || (required + optional < 2 && !rest)
|
|
91
|
+
|
|
92
|
+
raise ConfigurationError, "sink #{sink.is_a?(Module) ? sink.name : sink.class} must take (signal, data) or (signal, data, context)"
|
|
93
|
+
end
|
|
94
|
+
|
|
39
95
|
# Reports every signal as an ActiveSupport::Notifications event named "<signal>.graphql_armory", with the
|
|
40
96
|
# signal's data as payload: `sinks: [GraphQL::Armory::Telemetry::Notifications]`.
|
|
41
97
|
module Notifications
|
|
@@ -50,10 +106,11 @@ module GraphQL
|
|
|
50
106
|
|
|
51
107
|
# Keeps what was recorded and fails when told, for tests.
|
|
52
108
|
class Substitute
|
|
53
|
-
attr_reader :recorded
|
|
109
|
+
attr_reader :recorded, :contexts
|
|
54
110
|
|
|
55
111
|
def initialize
|
|
56
112
|
@recorded = []
|
|
113
|
+
@contexts = []
|
|
57
114
|
@failure = nil
|
|
58
115
|
end
|
|
59
116
|
|
|
@@ -66,13 +123,18 @@ module GraphQL
|
|
|
66
123
|
@failure = nil
|
|
67
124
|
end
|
|
68
125
|
|
|
69
|
-
def record(signal, **data)
|
|
126
|
+
def record(signal, context: nil, **data)
|
|
127
|
+
@contexts << context
|
|
70
128
|
raise @failure if @failure
|
|
71
129
|
|
|
72
130
|
@recorded << [signal, data]
|
|
73
131
|
nil
|
|
74
132
|
end
|
|
75
133
|
|
|
134
|
+
def listening?
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
76
138
|
def recorded?(signal)
|
|
77
139
|
@recorded.any? { |recorded_signal, _| recorded_signal == signal }
|
|
78
140
|
end
|
data/lib/graphql/armory.rb
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
require "graphql/armory"
|
|
4
4
|
|
|
5
5
|
namespace :graphql_armory do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
exit
|
|
6
|
+
# The Railtie loads this before Rails defines :environment, so the action looks it up when it runs. An app may also load this by hand.
|
|
7
|
+
action = GraphQL::Armory::Authorization::Audit::Command::RAKE_ACTION
|
|
8
|
+
unless Rake::Task.task_defined?("graphql_armory:audit") && Rake::Task["graphql_armory:audit"].actions.include?(action)
|
|
9
|
+
desc "Lists every type, field and argument of a schema that no authorization rule covers (exit 1 if any)"
|
|
10
|
+
task :audit, [:schema, :baseline], &action
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
baseline_action = GraphQL::Armory::Authorization::Audit::Command::BASELINE_ACTION
|
|
14
|
+
unless Rake::Task.task_defined?("graphql_armory:audit_baseline") && Rake::Task["graphql_armory:audit_baseline"].actions.include?(baseline_action)
|
|
15
|
+
desc "Writes the audit's problems to a baseline file, or removes from it what is gone; it never adds to a file that exists"
|
|
16
|
+
task :audit_baseline, [:schema, :baseline], &baseline_action
|
|
10
17
|
end
|
|
11
18
|
end
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-armory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.pre9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Israel
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -33,7 +33,8 @@ description: 'Open-source extensions for graphql-ruby built on its public extens
|
|
|
33
33
|
points. The first module is authorization: deny-by-default coverage, scoped lists
|
|
34
34
|
and connections, IDOR-safe lookups, mutations, interfaces and unions, visibility
|
|
35
35
|
driven by the policy, and a schema audit for CI.'
|
|
36
|
-
executables:
|
|
36
|
+
executables:
|
|
37
|
+
- graphql-armory-publish
|
|
37
38
|
extensions: []
|
|
38
39
|
extra_rdoc_files: []
|
|
39
40
|
files:
|
|
@@ -47,6 +48,7 @@ files:
|
|
|
47
48
|
- docs/persisted-queries.md
|
|
48
49
|
- docs/rate-limiting.md
|
|
49
50
|
- docs/telemetry.md
|
|
51
|
+
- exe/graphql-armory-publish
|
|
50
52
|
- lib/graphql-armory.rb
|
|
51
53
|
- lib/graphql/armory.rb
|
|
52
54
|
- lib/graphql/armory/authorization.rb
|
|
@@ -55,6 +57,7 @@ files:
|
|
|
55
57
|
- lib/graphql/armory/authorization/adapters/pundit.rb
|
|
56
58
|
- lib/graphql/armory/authorization/adapters/substitute.rb
|
|
57
59
|
- lib/graphql/armory/authorization/audit.rb
|
|
60
|
+
- lib/graphql/armory/authorization/audit/baseline.rb
|
|
58
61
|
- lib/graphql/armory/authorization/audit/command.rb
|
|
59
62
|
- lib/graphql/armory/authorization/audit/denial_hooks.rb
|
|
60
63
|
- lib/graphql/armory/authorization/audit/edge_rules.rb
|
|
@@ -64,8 +67,12 @@ files:
|
|
|
64
67
|
- lib/graphql/armory/authorization/audit/interface_rules.rb
|
|
65
68
|
- lib/graphql/armory/authorization/audit/introspection_fields.rb
|
|
66
69
|
- lib/graphql/armory/authorization/audit/library_methods.rb
|
|
70
|
+
- lib/graphql/armory/authorization/audit/observed_rules.rb
|
|
67
71
|
- lib/graphql/armory/authorization/audit/overrides.rb
|
|
68
72
|
- lib/graphql/armory/authorization/audit/own_checks.rb
|
|
73
|
+
- lib/graphql/armory/authorization/audit/payload_reuse.rb
|
|
74
|
+
- lib/graphql/armory/authorization/audit/plain_resolvers.rb
|
|
75
|
+
- lib/graphql/armory/authorization/audit/reach.rb
|
|
69
76
|
- lib/graphql/armory/authorization/audit/report.rb
|
|
70
77
|
- lib/graphql/armory/authorization/audit/type_rules.rb
|
|
71
78
|
- lib/graphql/armory/authorization/audit/visibility_models.rb
|
|
@@ -103,8 +110,13 @@ files:
|
|
|
103
110
|
- lib/graphql/armory/persisted_queries.rb
|
|
104
111
|
- lib/graphql/armory/persisted_queries/controls.rb
|
|
105
112
|
- lib/graphql/armory/persisted_queries/manifest.rb
|
|
113
|
+
- lib/graphql/armory/persisted_queries/manifests.rb
|
|
114
|
+
- lib/graphql/armory/persisted_queries/publisher.rb
|
|
115
|
+
- lib/graphql/armory/persisted_queries/redis_store.rb
|
|
106
116
|
- lib/graphql/armory/persisted_queries/store.rb
|
|
117
|
+
- lib/graphql/armory/railtie.rb
|
|
107
118
|
- lib/graphql/armory/rate_limits.rb
|
|
119
|
+
- lib/graphql/armory/rate_limits/analysis_guard.rb
|
|
108
120
|
- lib/graphql/armory/rate_limits/clock.rb
|
|
109
121
|
- lib/graphql/armory/rate_limits/concurrency.rb
|
|
110
122
|
- lib/graphql/armory/rate_limits/controls.rb
|