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
@@ -41,7 +41,7 @@ module GraphQL
41
41
  return Adapters::Null.new.scope(nil, items, rule) if rule.nil?
42
42
 
43
43
  configuration = configuration(context)
44
- Scope.(items, rule, context, adapter: configuration.adapter, sinks: configuration.sinks)
44
+ Scope.(items, rule, context, adapter: configuration.adapter, sinks: configuration.sinks, on_sink_error: configuration.on_sink_error)
45
45
  end
46
46
 
47
47
  # Looks an object up inside the user's scope, by primary key unless a key is given.
@@ -60,7 +60,7 @@ module GraphQL
60
60
 
61
61
  # An in-memory scope yields an Array, which cannot look one record up: find it, then ask the rule.
62
62
  record = model.find_by(conditions)
63
- record if record && allowed?(record, rule, context)
63
+ record if record && allowed?(record, rule.enforced, context)
64
64
  end
65
65
 
66
66
  # Loads a `loads:` argument inside the user's scope. When the schema defines `object_from_id` (global
@@ -124,12 +124,23 @@ module GraphQL
124
124
  end
125
125
  private_class_method :declared_model
126
126
 
127
+ # Observing a type opens the only check that stands before everything inside it, whatever class its fields are. A
128
+ # resolver or a mutation can write, and a write cannot be undone, so a type holding one cannot be observed.
129
+ def self.refuse_unobservable(type, context)
130
+ known = context.namespace(:armory)[:observable] ||= {}.compare_by_identity
131
+ field = known.fetch(type) { known[type] = type.all_field_definitions.find(&:resolver) }
132
+ return unless field
133
+
134
+ raise ConfigurationError, "#{type.graphql_name} cannot be observed: its field #{field.graphql_name} has a resolver or a mutation, which can write. " \
135
+ "Remove observe:, or put it on its plain fields"
136
+ end
137
+
127
138
  # One Authorize per query: building it (and its memo and telemetry) once per object is pure overhead.
128
139
  def self.allowed?(subject, rule, context)
129
140
  store = context.namespace(:armory)
130
141
  authorize = store[:authorize] ||= begin
131
142
  configuration = configuration(context)
132
- Authorize.build(context, adapter: configuration.adapter, sinks: configuration.sinks)
143
+ Authorize.build(context, adapter: configuration.adapter, sinks: configuration.sinks, on_sink_error: configuration.on_sink_error)
133
144
  end
134
145
 
135
146
  authorize.(subject, rule, context)
@@ -5,10 +5,16 @@ require "rspec/expectations"
5
5
  # `require "graphql/armory/authorization/rspec"` in your spec helper, then:
6
6
  #
7
7
  # expect(AppSchema).to be_fully_authorized
8
- RSpec::Matchers.define :be_fully_authorized do
8
+ # expect(AppSchema).to be_fully_authorized(baseline: "config/graphql_armory_baseline.json") # fails only on new problems
9
+ RSpec::Matchers.define :be_fully_authorized do |*positional, baseline: nil|
10
+ raise ArgumentError, "be_fully_authorized takes baseline: path" unless positional.empty?
11
+
9
12
  match do |schema|
10
- @report = GraphQL::Armory::Authorization::Audit.(schema)
11
- @report.ok?
13
+ report = GraphQL::Armory::Authorization::Audit.(schema)
14
+ next (@report = report).ok? unless baseline
15
+
16
+ @report = GraphQL::Armory::Authorization::Audit::Baseline.load(baseline).(report)
17
+ @report.passes?
12
18
  end
13
19
 
14
20
  failure_message do |schema|
@@ -16,6 +22,6 @@ RSpec::Matchers.define :be_fully_authorized do
16
22
  end
17
23
 
18
24
  failure_message_when_negated do |schema|
19
- "expected #{schema} not to be fully authorized, but the audit found no problems"
25
+ baseline ? "expected #{schema} not to pass its baseline, but it did:\n#{@report}" : "expected #{schema} not to be fully authorized, but the audit found no problems"
20
26
  end
21
27
  end
@@ -6,13 +6,13 @@ module GraphQL
6
6
  # Immutable value holding what was declared, e.g. `authorize :read`.
7
7
  # Each adapter reads the parts it needs (policy, subject, attribute, model, scope).
8
8
  class Rule
9
- attr_reader :action, :policy, :subject, :attribute, :model, :scope, :visible_when, :covers_fields, :reason
9
+ attr_reader :action, :policy, :subject, :attribute, :model, :scope, :visible_when, :covers_fields, :reason, :observe
10
10
 
11
11
  def self.none(reason:)
12
12
  new(:none, reason: reason)
13
13
  end
14
14
 
15
- def initialize(action, policy: nil, subject: nil, attribute: nil, model: nil, scope: nil, visible_when: nil, covers_fields: false, reason: nil)
15
+ def initialize(action, policy: nil, subject: nil, attribute: nil, model: nil, scope: nil, visible_when: nil, covers_fields: false, reason: nil, observe: nil)
16
16
  raise ArgumentError, "a rule needs an action" if action.nil?
17
17
 
18
18
  @action = action.to_sym
@@ -24,19 +24,35 @@ module GraphQL
24
24
  @visible_when = visible_when&.to_sym
25
25
  @covers_fields = covers_fields
26
26
  @reason = reason
27
+ @observe = observe
27
28
 
28
29
  raise ArgumentError, "a bypass needs a reason" if none? && reason.to_s.strip.empty?
29
30
 
30
31
  raise ArgumentError, "covers_fields only applies to a bypass (`authorize :none`)" if covers_fields && !none?
32
+ raise ArgumentError, "observe: needs a text saying why this rule is observed, got #{observe.class}" unless observe.nil? || (observe.is_a?(String) && !observe.strip.empty?)
33
+ raise ArgumentError, "observe: does not apply to a bypass, which refuses nothing" if observe && none?
31
34
 
32
35
  freeze
33
36
  end
34
37
 
35
38
  def with_model(model)
39
+ self.class.new(action, policy: policy, subject: subject, attribute: attribute, model: model, scope: scope,
40
+ visible_when: visible_when, covers_fields: covers_fields, reason: reason, observe: observe)
41
+ end
42
+
43
+ # The same rule with no observation, for what must keep refusing: lookups through a scope.
44
+ def enforced
45
+ return self unless observed?
46
+
36
47
  self.class.new(action, policy: policy, subject: subject, attribute: attribute, model: model, scope: scope,
37
48
  visible_when: visible_when, covers_fields: covers_fields, reason: reason)
38
49
  end
39
50
 
51
+ # A clear "no" to this rule, on a read, is served and recorded instead of refused. See Authorize.
52
+ def observed?
53
+ !observe.nil?
54
+ end
55
+
40
56
  # A bypassed type whose fields are covered by something else, such as the payload of a mutation.
41
57
  def covers_fields?
42
58
  covers_fields
@@ -58,7 +74,7 @@ module GraphQL
58
74
  protected
59
75
 
60
76
  def members
61
- [action, policy, subject, attribute, model, scope, visible_when, covers_fields, reason]
77
+ [action, policy, subject, attribute, model, scope, visible_when, covers_fields, reason, observe]
62
78
  end
63
79
  end
64
80
  end
@@ -13,22 +13,22 @@ module GraphQL
13
13
  @telemetry = telemetry
14
14
  end
15
15
 
16
- def self.build(adapter:, sinks: [])
17
- new(adapter: adapter, telemetry: Telemetry.build(sinks: sinks))
16
+ def self.build(adapter:, sinks: [], on_sink_error: nil)
17
+ new(adapter: adapter, telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
18
18
  end
19
19
 
20
- def self.call(items, rule, context, adapter:, sinks: [])
21
- build(adapter: adapter, sinks: sinks).(items, rule, context)
20
+ def self.call(items, rule, context, adapter:, sinks: [], on_sink_error: nil)
21
+ build(adapter: adapter, sinks: sinks, on_sink_error: on_sink_error).(items, rule, context)
22
22
  end
23
23
 
24
24
  def call(items, rule, context)
25
25
  if rule.none?
26
- telemetry.record(:bypassed, reason: rule.reason)
26
+ telemetry.record(:bypassed, context: context, reason: rule.reason)
27
27
  return items
28
28
  end
29
29
 
30
30
  scoped = adapter.scope(adapter.actor(context), items, rule)
31
- telemetry.record(:scoped, action: rule.action)
31
+ telemetry.record(:scoped, context: context, action: rule.action)
32
32
  scoped
33
33
  end
34
34
  end
@@ -36,7 +36,7 @@ module GraphQL
36
36
  module Authorization
37
37
  # The schema plugin: `use GraphQL::Armory::Authorization, adapter: :pundit`.
38
38
  # Options: `adapter:` (:pundit or an adapter object), `on_unauthorized:` (:null, :error or :raise),
39
- # `sinks:` (callables that receive each telemetry signal), `own_checks:` (an app's own `authorized?`, `authorizes?`, `authorized_new`, `wrap`,
39
+ # `sinks:` (callables that receive each telemetry signal), `on_sink_error:` (called with the error and the signal when a sink raises), `own_checks:` (an app's own `authorized?`, `authorizes?`, `authorized_new`, `wrap`,
40
40
  # `unauthorized_object`, `visible?`, `scope_items` or `reauthorize_scoped_objects(false)` on a class, with the reason, for the audit), `own_denial_hooks:` (the schema's own
41
41
  # `unauthorized_field` or `unauthorized_object`, each with the reason, for the audit).
42
42
  def self.use(schema_defn, **options)
@@ -16,8 +16,8 @@ module GraphQL
16
16
  module IDs
17
17
  SEPARATOR = "/"
18
18
 
19
- def self.use(schema, encoder: nil, find: nil, sinks: [])
20
- lookup = Lookup.build(encoder: encoder, find: find, sinks: sinks)
19
+ def self.use(schema, encoder: nil, find: nil, sinks: [], on_sink_error: nil)
20
+ lookup = Lookup.build(encoder: encoder, find: find, sinks: sinks, on_sink_error: on_sink_error)
21
21
  schema.define_singleton_method(:id_from_object) { |object, type, _context| lookup.id_for(object, type) }
22
22
  schema.define_singleton_method(:object_from_id) { |id, context| lookup.object_for(id, context) }
23
23
  end
@@ -27,8 +27,8 @@ module GraphQL
27
27
  attr_reader :encoder, :find, :telemetry
28
28
 
29
29
  # Wires the default encoder unless one is given; `use` goes through here.
30
- def self.build(encoder: nil, find: nil, sinks: [])
31
- new(encoder: encoder || Encoder.default, find: find, telemetry: Telemetry.build(sinks: sinks))
30
+ def self.build(encoder: nil, find: nil, sinks: [], on_sink_error: nil)
31
+ new(encoder: encoder || Encoder.default, find: find, telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
32
32
  end
33
33
 
34
34
  # `new` is inert: with no encoder wired it hands out no ID and honours none. It checks what it is given.
@@ -52,25 +52,25 @@ module GraphQL
52
52
 
53
53
  def object_for(id, context)
54
54
  type_name, key = encoder.decode(id).split(SEPARATOR, 2)
55
- return refuse(:malformed) if key.nil? || key.empty?
55
+ return refuse(:malformed, context) if key.nil? || key.empty?
56
56
 
57
57
  type = visible_type(type_name, context)
58
- return refuse(:unknown_type) unless node_type?(type)
58
+ return refuse(:unknown_type, context) unless node_type?(type)
59
59
 
60
60
  object = load(type, key, context)
61
- return refuse(:not_found, type: type.graphql_name) if object.nil?
61
+ return refuse(:not_found, context, type: type.graphql_name) if object.nil?
62
62
 
63
- telemetry.record(:id_resolved, type: type.graphql_name)
63
+ telemetry.record(:id_resolved, context: context, type: type.graphql_name)
64
64
  object
65
65
  rescue Encoder::InvalidError
66
- refuse(:invalid)
66
+ refuse(:invalid, context)
67
67
  end
68
68
 
69
69
  private
70
70
 
71
71
  # Every refusal answers nil, like a missing record; the signal says why, never which ID.
72
- def refuse(reason, **data)
73
- telemetry.record(:id_refused, reason: reason, **data)
72
+ def refuse(reason, context, **data)
73
+ telemetry.record(:id_refused, context: context, reason: reason, **data)
74
74
  nil
75
75
  end
76
76
 
@@ -17,6 +17,8 @@ module GraphQL
17
17
 
18
18
  Operation = Struct.new(:id, :name, :body, :document, keyword_init: true)
19
19
 
20
+ attr_reader :operations
21
+
20
22
  def self.load(path, digest: :sha256)
21
23
  data = begin
22
24
  JSON.parse(File.read(path, mode: "r:bom|utf-8"))
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module PersistedQueries
6
+ # Several manifests behind one `store:`: each frontend's build, and every mobile version still in use.
7
+ #
8
+ # use GraphQL::Armory::PersistedQueries, store: Manifests.build("config/persisted_queries/*.json"), strict: true
9
+ #
10
+ # A client's id is looked up across all of them, so any client may run any manifest's operation: they are one
11
+ # allowlist, not one per client. Any problem raises at boot.
12
+ class Manifests
13
+ GLOB = /[*?\[{]/
14
+
15
+ # `paths` is a glob pattern, a path, or a list of them. One that finds nothing raises: in strict mode its clients
16
+ # would all be refused. A glob skips dotfiles, and drops a missing `{a,b}` alternative: list exact paths to be sure.
17
+ def self.build(paths, digest: :sha256)
18
+ names = paths.is_a?(Array) ? paths : [paths]
19
+ raise ConfigurationError, "no manifest found in an empty list" if names.empty?
20
+
21
+ files = names.flat_map { |name| files_of(name) }.uniq
22
+ new(files.map { |file| Manifest.load(file, digest: digest) })
23
+ end
24
+
25
+ def self.files_of(name)
26
+ raise ConfigurationError, "manifest paths must be Strings or Pathnames, found a #{name.class}" unless name.is_a?(String) || name.respond_to?(:to_path)
27
+
28
+ path = name.is_a?(String) ? name : name.to_path
29
+ return [path] unless path.match?(GLOB)
30
+
31
+ found = Dir.glob(path).sort
32
+ found = [path] if found.empty? && File.file?(path) # a name with a `[`, which matched nothing as a pattern
33
+ raise ConfigurationError, "no manifest found in #{path}" if found.empty?
34
+
35
+ found
36
+ end
37
+ private_class_method :files_of
38
+
39
+ # `new` is inert: with no manifests it holds no operation, so every id is unknown.
40
+ def initialize(manifests = [])
41
+ unless manifests.is_a?(Array) && manifests.all? { |manifest| manifest.instance_of?(Manifest) }
42
+ raise ConfigurationError, "manifests must be an Array of Manifest; use Manifests.build for paths"
43
+ end
44
+
45
+ @operations = manifests.each_with_object({}) do |manifest, operations|
46
+ manifest.operations.each do |id, operation|
47
+ kept = operations[id] ||= operation
48
+ next if kept.body == operation.body
49
+
50
+ raise ConfigurationError, "two manifests hold different queries for #{id.match?(Manifest::ID_LIKE) ? "the id #{id}" : "one id"}"
51
+ end
52
+ end.freeze
53
+ end
54
+
55
+ def fetch(id)
56
+ @operations[id]
57
+ end
58
+
59
+ def size
60
+ @operations.size
61
+ end
62
+
63
+ def empty?
64
+ @operations.empty?
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module PersistedQueries
8
+ # Publishes a client build's manifest to Redis, for `RedisStore` to read. It only adds: an id that already holds
9
+ # another query raises and stays as it was. Publishing again is safe, so a failed run can simply run again.
10
+ #
11
+ # GraphQL::Armory::PersistedQueries::Publisher.call(Manifest.load("manifest.json"), redis: Redis.new(url: url))
12
+ class Publisher
13
+ # An id it could not store, or that holds another query. The message quotes neither query.
14
+ class PublishError < Armory::Error; end
15
+
16
+ Result = Struct.new(:published, :unchanged, keyword_init: true)
17
+
18
+ def self.build(redis:, prefix: RedisStore::DEFAULT_PREFIX)
19
+ raise ConfigurationError, "redis: must answer get and set, like Redis.new from the redis gem" unless redis.respond_to?(:get) && redis.respond_to?(:set)
20
+ raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
21
+
22
+ new(redis: redis, prefix: prefix)
23
+ end
24
+
25
+ def self.call(manifest, **options)
26
+ build(**options).(manifest)
27
+ end
28
+
29
+ # `new` is inert: with no Redis wired, nothing is stored and `call` raises.
30
+ def initialize(redis: RedisStore::Client::Null.new, prefix: RedisStore::DEFAULT_PREFIX)
31
+ @redis = redis
32
+ @prefix = prefix
33
+ end
34
+
35
+ def call(manifest)
36
+ raise ConfigurationError, "publish a Manifest: Manifest.load checks every id and query" unless manifest.instance_of?(Manifest)
37
+
38
+ check_ids(manifest)
39
+ published = manifest.operations.count { |id, operation| publish(id, operation.body) }
40
+ Result.new(published: published, unchanged: manifest.size - published)
41
+ end
42
+
43
+ private
44
+
45
+ # Before anything is stored: `RedisStore` never asks for an id of another shape.
46
+ def check_ids(manifest)
47
+ manifest.operations.keys.each_with_index do |id, index|
48
+ raise ConfigurationError, "the operation at entry #{index} has an id RedisStore cannot read" unless id.match?(Manifest::ID_LIKE)
49
+ end
50
+ end
51
+
52
+ # Answers whether it was new. SET NX, so two runs at once cannot overwrite each other.
53
+ def publish(id, body)
54
+ key = "#{@prefix}#{id}"
55
+ return true if @redis.set(key, body, nx: true)
56
+
57
+ stored = @redis.get(key)
58
+ raise PublishError, "operation #{id} was not stored" if stored.nil?
59
+ raise PublishError, "operation #{id} already holds another query: published operations never change" unless stored.b == body.b
60
+
61
+ false
62
+ end
63
+
64
+ # The `graphql-armory-publish` executable, for a client's CI. Answers the exit code. Redis's messages may hold
65
+ # the URL and its password, so only the error's class is printed.
66
+ class Command
67
+ USAGE = "Usage: graphql-armory-publish [--digest sha256|sha1|md5|false] [--prefix PREFIX] MANIFEST...\nReads REDIS_URL."
68
+
69
+ def self.call(argv = ARGV, env: ENV, out: $stdout, err: $stderr, client: nil)
70
+ new(env: env, out: out, err: err, client: client).(argv)
71
+ end
72
+
73
+ def initialize(env: {}, out: $stdout, err: $stderr, client: nil)
74
+ @env = env
75
+ @out = out
76
+ @err = err
77
+ @client = client
78
+ end
79
+
80
+ def call(argv)
81
+ digest, prefix, paths = options(argv)
82
+ return fail_with(USAGE) if paths.empty?
83
+
84
+ url = @env["REDIS_URL"]
85
+ return fail_with("REDIS_URL is not set") if url.to_s.empty?
86
+
87
+ manifests = paths.map { |path| [path, Manifest.load(path, digest: digest)] }
88
+ publisher = Publisher.build(redis: @client || redis(url), prefix: prefix)
89
+ manifests.each do |path, manifest|
90
+ result = publisher.(manifest)
91
+ @out.puts "#{path}: #{result.published} published, #{result.unchanged} unchanged"
92
+ end
93
+ 0
94
+ rescue OptionParser::ParseError, Armory::Error => error
95
+ fail_with(error.message)
96
+ rescue LoadError
97
+ fail_with("graphql-armory-publish needs the redis gem: gem install redis")
98
+ rescue StandardError => error
99
+ fail_with("publishing failed (#{error.class})")
100
+ end
101
+
102
+ private
103
+
104
+ def options(argv)
105
+ digest = :sha256
106
+ prefix = RedisStore::DEFAULT_PREFIX
107
+ paths = OptionParser.new do |parser|
108
+ parser.on("--digest NAME") { |name| digest = name == "false" ? false : name.to_sym }
109
+ parser.on("--prefix PREFIX") { |given| prefix = given }
110
+ end.parse(argv)
111
+ [digest, prefix, paths]
112
+ end
113
+
114
+ def redis(url)
115
+ require "redis"
116
+ Redis.new(url: url)
117
+ end
118
+
119
+ def fail_with(message)
120
+ @err.puts message
121
+ 1
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module PersistedQueries
6
+ # Operations a client's CI published with `Publisher`, read while the server runs, so a new frontend needs no
7
+ # server deploy.
8
+ #
9
+ # store = GraphQL::Armory::PersistedQueries::RedisStore.build(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
10
+ #
11
+ # An operation it read stays in memory: published operations never change, so what it already knows keeps working
12
+ # when Redis fails. An id it cannot find, or cannot check, is unknown: nothing runs. This gem does not depend on
13
+ # the redis gem: the app adds it.
14
+ class RedisStore
15
+ DEFAULT_PREFIX = "graphql-armory:persisted-queries:"
16
+ DEFAULT_ON_ERROR = ->(error) { warn "graphql-armory: persisted query store failed (#{error.class})" }
17
+
18
+ # A stored value that does not parse, or is not the digest of its id. The message quotes neither.
19
+ class CorruptOperation < Armory::Error; end
20
+
21
+ # What the store and the publisher need from Redis.
22
+ module Client
23
+ # The Redis of a store nobody wired: it holds nothing and stores nothing.
24
+ class Null
25
+ def get(_key)
26
+ nil
27
+ end
28
+
29
+ def set(_key, _value, nx: false)
30
+ nil
31
+ end
32
+ end
33
+
34
+ # Holds its keys in memory like Redis, records its calls and fails when told: for tests.
35
+ class Substitute
36
+ attr_reader :calls
37
+
38
+ def initialize
39
+ @data = {}
40
+ @calls = []
41
+ @failure = nil
42
+ end
43
+
44
+ # Raises `error` from every call until `recover`.
45
+ def fail_with(error)
46
+ @failure = error
47
+ end
48
+
49
+ def recover
50
+ @failure = nil
51
+ end
52
+
53
+ def get(key)
54
+ @calls << [:get, key]
55
+ raise @failure if @failure
56
+
57
+ @data[key]
58
+ end
59
+
60
+ def set(key, value, nx: false)
61
+ @calls << [:set, key]
62
+ raise @failure if @failure
63
+ return false if nx && @data.key?(key)
64
+
65
+ @data[key] = value
66
+ true
67
+ end
68
+ end
69
+ end
70
+
71
+ # Validates the settings and wires the real Redis client.
72
+ def self.build(redis:, prefix: DEFAULT_PREFIX, digest: :sha256, on_error: DEFAULT_ON_ERROR)
73
+ raise ConfigurationError, "redis: must answer get and set, like Redis.new from the redis gem" unless redis.respond_to?(:get) && redis.respond_to?(:set)
74
+ raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
75
+ unless digest == false || Manifest::DIGESTS.key?(digest)
76
+ raise ConfigurationError, "unknown digest #{digest.inspect}: use #{Manifest::DIGESTS.keys.map(&:inspect).join(", ")} or false"
77
+ end
78
+ raise ConfigurationError, "on_error: must be a callable that takes the error" unless on_error.respond_to?(:call)
79
+
80
+ new(redis: redis, prefix: prefix, digest: digest, on_error: on_error)
81
+ end
82
+
83
+ # `new` is inert: with no Redis wired it holds no operation, so every id is unknown.
84
+ def initialize(redis: Client::Null.new, prefix: DEFAULT_PREFIX, digest: :sha256, on_error: DEFAULT_ON_ERROR)
85
+ @redis = redis
86
+ @prefix = prefix
87
+ @digest = digest == false ? nil : Manifest::DIGESTS.fetch(digest)
88
+ @on_error = on_error
89
+ @operations = {}
90
+ @mutex = Mutex.new
91
+ end
92
+
93
+ def fetch(id)
94
+ # Bytes: a regexp raises on a client's broken encoding.
95
+ return unless id.is_a?(String) && id.b.match?(Manifest::ID_LIKE)
96
+
97
+ @operations[id] || read(id)
98
+ end
99
+
100
+ # Only an unwired store is known to be empty: `strict: false` refuses it at boot, as it does the Null store.
101
+ def empty?
102
+ @redis.is_a?(Client::Null)
103
+ end
104
+
105
+ private
106
+
107
+ # Only what it found is remembered: an unknown id may be published a moment later.
108
+ def read(id)
109
+ body = @redis.get("#{@prefix}#{id}")
110
+ return if body.nil?
111
+
112
+ operation = operation(id, body)
113
+ @mutex.synchronize { @operations[id] ||= operation }
114
+ rescue StandardError => error
115
+ @on_error.call(error)
116
+ nil
117
+ end
118
+
119
+ def operation(id, stored)
120
+ raise CorruptOperation, "operation #{id} is not text" unless stored.is_a?(String)
121
+
122
+ # redis-rb may answer in another encoding; a query compares as text with what the client sends.
123
+ body = stored.dup.force_encoding(Encoding::UTF_8).freeze
124
+ raise CorruptOperation, "operation #{id} is not the digest of its query" if @digest && @digest.hexdigest(body) != id
125
+
126
+ Manifest::Operation.new(id: id.dup.freeze, body: body, document: parse(id, body)).freeze
127
+ end
128
+
129
+ def parse(id, body)
130
+ GraphQL.parse(body)
131
+ rescue GraphQL::ParseError
132
+ raise CorruptOperation, "operation #{id} does not parse"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end