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
@@ -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) { define_method(:time) { time } })
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
- answer = @store.take(debt[:key], cost: cost, limit: debt[:limit], window: @window, force: true)
140
- _taken, remaining = Settings.checked(answer, :take, "the Integer milliseconds left")
141
- settle(query, debt, cost, remaining)
142
- rescue StandardError => error
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
- return ->(error, _query) { warn("graphql-armory rate limit store failed, query let through: #{error.class}: #{error.message}") } if handler.nil?
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
- handler
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`. Never put record attributes, IDs, keys or queries in `data`: a client chooses
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
- def self.build(sinks: [])
13
- new(sinks: sinks)
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
- @sinks = sinks.dup.freeze
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.each { |sink| sink.call(signal, data) }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Armory
5
- VERSION = "0.1.0.pre8"
5
+ VERSION = "0.1.0.pre9"
6
6
  end
7
7
  end
@@ -17,3 +17,4 @@ require "graphql/armory/ids"
17
17
  require "graphql/armory/persisted_queries"
18
18
  require "graphql/armory/rate_limits"
19
19
  require "graphql/armory/keyset"
20
+ require "graphql/armory/railtie" if defined?(::Rails::Railtie)
@@ -3,9 +3,16 @@
3
3
  require "graphql/armory"
4
4
 
5
5
  namespace :graphql_armory do
6
- desc "Lists every type, field and argument of a schema that no authorization rule covers (exit 1 if any)"
7
- task :audit, [:schema] => (Rake::Task.task_defined?(:environment) ? [:environment] : []) do |_task, args|
8
- status = GraphQL::Armory::Authorization::Audit::Command.new.(args[:schema])
9
- exit(status) unless status.zero?
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.pre8
4
+ version: 0.1.0.pre9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Israel
8
- bindir: bin
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