graphql-armory 0.1.0.pre2 → 0.1.0.pre4

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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ class Encoder
6
+ ENV_KEY = "GRAPHQL_ARMORY_KEY"
7
+ RAILS_PURPOSE = "graphql-armory/ids"
8
+
9
+ # An encoder whose key comes from the app, never from the gem: `GRAPHQL_ARMORY_KEY`, or else a key derived
10
+ # with HKDF from Rails' secret_key_base for this purpose alone. Without either it raises, so the app fails
11
+ # when the schema loads instead of serving unencrypted IDs.
12
+ #
13
+ # It reads `secret_key_base` instead of `Rails.application.key_generator`: that generator is built once per
14
+ # process with whatever digest Rails has configured at that moment, which changes during boot, so the same
15
+ # secret could give different IDs in a web server and in a rake task (and calling it early would also fix
16
+ # Rails' own cookie keys to the wrong digest).
17
+ def self.default(env: ENV, rails: rails_application)
18
+ secret = env_key(env)
19
+ secret = rails_key(rails) if secret.nil? && rails
20
+ if secret.nil?
21
+ raise ConfigurationError,
22
+ "no key for the default encoder: set #{ENV_KEY} (try `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`), " \
23
+ "or run inside a Rails app with a secret_key_base"
24
+ end
25
+
26
+ Class.new(self) { key(secret) }
27
+ end
28
+
29
+ def self.env_key(env)
30
+ value = env[ENV_KEY].to_s
31
+ return nil if value.empty?
32
+ if value.strip != value || value.strip.empty?
33
+ raise ConfigurationError, "#{ENV_KEY} has leading or trailing whitespace, or only whitespace: remove it"
34
+ end
35
+
36
+ value
37
+ end
38
+ private_class_method :env_key
39
+
40
+ def self.rails_application
41
+ ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
42
+ end
43
+ private_class_method :rails_application
44
+
45
+ def self.rails_key(rails)
46
+ secret = begin
47
+ rails.secret_key_base
48
+ rescue StandardError => error
49
+ # Rails' message may come from app code; name only its class so nothing secret is echoed.
50
+ raise ConfigurationError, "the default encoder could not read Rails' secret_key_base (#{error.class})"
51
+ end
52
+ raise ConfigurationError, "this Rails app has no secret_key_base for the default encoder" if secret.to_s.empty?
53
+
54
+ OpenSSL::KDF.hkdf(secret.to_s, salt: "graphql-armory", info: RAILS_PURPOSE, length: 32, hash: "SHA256")
55
+ end
56
+ private_class_method :rails_key
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ class Encoder
8
+ # graphql-ruby's default base64, to keep reading the cursors and IDs clients already hold while migrating to
9
+ # encryption: put it last in `Encoder.versioned`. It reads base64 padded or not, url-safe or standard, and
10
+ # only when it holds printable text, so a tampered encrypted value never comes back as garbage. It is not
11
+ # encryption: anyone can forge what it reads. Remove it from the chain once old values have expired.
12
+ module Legacy
13
+ PRINTABLE = /\A[[:print:]]*\z/
14
+
15
+ def self.encode(text, nonce: false)
16
+ Base64.urlsafe_encode64(text.to_s, padding: false)
17
+ end
18
+
19
+ def self.decode(text, nonce: false)
20
+ raise InvalidError unless text.is_a?(String) && !text.empty?
21
+
22
+ plain = Base64.urlsafe_decode64(text.tr("+/", "-_").delete_suffix("==").delete_suffix("="))
23
+ raise InvalidError if plain.empty?
24
+ plain.force_encoding(Encoding::UTF_8)
25
+ raise InvalidError unless plain.valid_encoding? && plain.match?(PRINTABLE)
26
+
27
+ plain
28
+ rescue StandardError
29
+ raise InvalidError
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ class Encoder
6
+ # A chain of encoders, newest first: encodes with the first, decodes with whichever accepts the value.
7
+ # Build it with `Encoder.versioned(NewEncoder, OldEncoder, Encoder::Legacy)`.
8
+ class Versioned
9
+ attr_reader :encoders
10
+
11
+ def initialize(encoders)
12
+ raise ConfigurationError, "a versioned encoder needs at least one encoder" if encoders.empty?
13
+
14
+ encoders.each { |encoder| check(encoder) }
15
+ @encoders = encoders.freeze
16
+ end
17
+
18
+ def encode(text, nonce: false)
19
+ encoders.first.encode(text, nonce: nonce)
20
+ end
21
+
22
+ def decode(text, nonce: false)
23
+ data, encoder = decode_versioned(text, nonce: nonce)
24
+ raise InvalidError unless encoder
25
+
26
+ data
27
+ end
28
+
29
+ # The data and the encoder that decoded it, or `[nil, nil]`, so the app can treat old values differently.
30
+ def decode_versioned(text, nonce: false)
31
+ encoders.each do |encoder|
32
+ data = encoder.decode(text, nonce: nonce)
33
+ return [data, encoder] if data.is_a?(String)
34
+ rescue ConfigurationError
35
+ raise
36
+ rescue StandardError
37
+ next
38
+ end
39
+ [nil, nil]
40
+ end
41
+
42
+ private
43
+
44
+ # Fails at boot for a member that would otherwise refuse every value at request time.
45
+ def check(encoder)
46
+ unless %i[encode decode].all? { |name| encoder.respond_to?(name) }
47
+ raise ConfigurationError, "#{encoder.inspect} cannot be versioned: it must respond to encode and decode"
48
+ end
49
+ unless %i[encode decode].all? { |name| accepts_nonce?(encoder.method(name)) }
50
+ raise ConfigurationError, "#{encoder.inspect} cannot be versioned: encode and decode must accept `nonce:`"
51
+ end
52
+ return unless encoder.respond_to?(:key) && encoder.respond_to?(:tag) && encoder.key.nil?
53
+
54
+ raise ConfigurationError, "#{encoder.inspect} cannot be versioned: it has no key"
55
+ end
56
+
57
+ def accepts_nonce?(method)
58
+ method.parameters.any? { |kind, name| kind == :keyrest || (%i[key keyreq].include?(kind) && name == :nonce) }
59
+ end
60
+ end
61
+
62
+ def self.versioned(*encoders)
63
+ Versioned.new(encoders)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module GraphQL
7
+ module Armory
8
+ # Encrypts and authenticates IDs and cursors with AES-256-GCM. Subclass it and set a key:
9
+ #
10
+ # class AppEncoder < GraphQL::Armory::Encoder
11
+ # key ENV.fetch("GRAPHQL_ARMORY_KEY")
12
+ # end
13
+ #
14
+ # Cursors (`nonce: true`, as graphql-ruby's connections ask) get a random nonce, so the same position
15
+ # never gives the same cursor. IDs must be stable, so their nonce is derived from the ID itself with an
16
+ # HMAC: the same ID always encrypts the same way, and two different IDs never share a nonce.
17
+ # IDs and cursors use separate keys, so one is never accepted as the other.
18
+ class Encoder
19
+ # Raised for anything that does not decode: tampered, foreign, malformed. The message never says which.
20
+ class InvalidError < GraphQL::ExecutionError
21
+ def initialize(message = "Invalid ID or cursor")
22
+ super
23
+ end
24
+ end
25
+
26
+ class ConfigurationError < Armory::Error; end
27
+
28
+ # graphql-ruby's default text form for cursors: base64url without padding.
29
+ module URLSafeBase64
30
+ def self.encode(bytes)
31
+ Base64.urlsafe_encode64(bytes, padding: false)
32
+ end
33
+
34
+ def self.decode(text)
35
+ Base64.urlsafe_decode64(text)
36
+ end
37
+ end
38
+
39
+ MINIMUM_KEY_BYTES = 32
40
+ NONCE_BYTES = 12
41
+ AUTH_TAG_BYTES = 16
42
+ CIPHER = "aes-256-gcm"
43
+
44
+ class << self
45
+ # The key, at least 32 bytes. With no argument, answers it (inherited from the parent class).
46
+ def key(*value)
47
+ return setting(:@key, nil) if value.empty?
48
+
49
+ secret = value.first
50
+ unless secret.is_a?(String) && secret.bytesize >= MINIMUM_KEY_BYTES
51
+ raise ConfigurationError, "an Encoder key must be a String of at least #{MINIMUM_KEY_BYTES} bytes (try SecureRandom.hex(32))"
52
+ end
53
+
54
+ @key = secret
55
+ end
56
+
57
+ # Authenticated data: an ID made with another tag does not decode. Use it to separate apps or versions.
58
+ def tag(*value)
59
+ return setting(:@tag, "") if value.empty?
60
+ raise ConfigurationError, "an Encoder tag cannot be nil" if value.first.nil?
61
+
62
+ @tag = value.first.to_s
63
+ end
64
+
65
+ # Turns the encrypted bytes into text and back. Anything with `encode(bytes)` and `decode(text)`.
66
+ def codec(*value)
67
+ return setting(:@codec, URLSafeBase64) if value.empty?
68
+ raise ConfigurationError, "an Encoder codec must respond to encode and decode" unless %i[encode decode].all? { |name| value.first.respond_to?(name) }
69
+
70
+ @codec = value.first
71
+ end
72
+
73
+ def encode(text, nonce: false)
74
+ purpose = nonce ? :cursor : :id
75
+ bytes = text.to_s.b
76
+ iv = nonce ? OpenSSL::Random.random_bytes(NONCE_BYTES) : derived_nonce(bytes)
77
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
78
+ cipher.key = subkey(purpose, :cipher)
79
+ cipher.iv = iv
80
+ cipher.auth_data = tag
81
+ sealed = cipher.update(bytes) + cipher.final
82
+ codec.encode(iv + sealed + cipher.auth_tag)
83
+ end
84
+
85
+ def decode(text, nonce: false)
86
+ purpose = nonce ? :cursor : :id
87
+ bytes = codec.decode(text.to_s)
88
+ # One text form only: a base64 variant with other padding bits is not the same ID.
89
+ raise InvalidError unless codec.encode(bytes) == text.to_s
90
+ raise InvalidError if bytes.bytesize < NONCE_BYTES + AUTH_TAG_BYTES
91
+
92
+ iv = bytes[0, NONCE_BYTES]
93
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
94
+ cipher.key = subkey(purpose, :cipher)
95
+ cipher.iv = iv
96
+ cipher.auth_tag = bytes[-AUTH_TAG_BYTES..]
97
+ cipher.auth_data = tag
98
+ plain = cipher.update(bytes[NONCE_BYTES...-AUTH_TAG_BYTES]) + cipher.final
99
+ # An ID has one valid form only: its nonce must be the one derived from it.
100
+ raise InvalidError if !nonce && !OpenSSL.fixed_length_secure_compare(iv, derived_nonce(plain))
101
+
102
+ plain.force_encoding(Encoding::UTF_8)
103
+ rescue ConfigurationError
104
+ raise
105
+ rescue StandardError
106
+ # Whatever the codec, OpenSSL or the input raise, the client learns only that it did not decode.
107
+ raise InvalidError
108
+ end
109
+
110
+ private
111
+
112
+ def setting(ivar, default)
113
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
114
+
115
+ superclass.respond_to?(:key) ? superclass.send(:setting, ivar, default) : default
116
+ end
117
+
118
+ def derived_nonce(bytes)
119
+ OpenSSL::HMAC.digest("SHA256", subkey(:id, :nonce), tag.b + "\0".b + bytes)[0, NONCE_BYTES]
120
+ end
121
+
122
+ # Separate keys per purpose and use, derived with HKDF and cached by the secret they come from, so a key
123
+ # changed later (here or in a parent class) is used at once.
124
+ def subkey(purpose, use)
125
+ secret = key or raise ConfigurationError, "#{name || "Encoder"} has no key: call `key` in its class body"
126
+ @subkeys ||= {}
127
+ @subkeys[[secret, purpose, use]] ||= OpenSSL::KDF.hkdf(secret, salt: "graphql-armory", info: "#{purpose}/#{use}",
128
+ length: 32, hash: "SHA256")
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ require "graphql/armory/encoder/versioned"
136
+ require "graphql/armory/encoder/legacy"
137
+ require "graphql/armory/encoder/default"
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ # Encrypted global IDs in one line:
6
+ #
7
+ # class AppSchema < GraphQL::Schema
8
+ # use GraphQL::Armory::IDs # the default encoder
9
+ # use GraphQL::Armory::IDs, encoder: AppEncoder # or your own
10
+ # end
11
+ #
12
+ # Defines `id_from_object` (the type name and the record's id, encrypted) and `object_from_id`, which only
13
+ # honours visible object types that implement Node. A type that
14
+ # includes the authorization module's Base is looked up inside the user's scope; any other type only through
15
+ # `find: ->(type, key, context) { ... }`. Every ID that cannot be honoured answers nil, like a missing record.
16
+ module IDs
17
+ SEPARATOR = "/"
18
+
19
+ def self.use(schema, encoder: nil, find: nil)
20
+ lookup = Lookup.new(encoder: encoder || Encoder.default, find: find)
21
+ schema.define_singleton_method(:id_from_object) { |object, type, _context| lookup.id_for(object, type) }
22
+ schema.define_singleton_method(:object_from_id) { |id, context| lookup.object_for(id, context) }
23
+ end
24
+
25
+ # Turns records into IDs and IDs back into records.
26
+ class Lookup
27
+ attr_reader :encoder, :find
28
+
29
+ def initialize(encoder:, find: nil)
30
+ @encoder = encoder
31
+ @find = find
32
+ end
33
+
34
+ def id_for(object, type)
35
+ key = object.id
36
+ raise Encoder::ConfigurationError, "#{type.graphql_name} object has no id to build a global ID from" if key.nil?
37
+
38
+ encoder.encode("#{type.graphql_name}#{SEPARATOR}#{key}")
39
+ end
40
+
41
+ def object_for(id, context)
42
+ type_name, key = encoder.decode(id).split(SEPARATOR, 2)
43
+ return nil if key.nil? || key.empty?
44
+
45
+ type = visible_type(type_name, context)
46
+ return nil unless node_type?(type)
47
+
48
+ load(type, key, context)
49
+ rescue Encoder::InvalidError
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # Through the query's own view of the schema: `Schema.get_type(name, context)` does not apply `visible?`
56
+ # when graphql-ruby runs without `Schema::Visibility`, `context.types` does in both modes.
57
+ def visible_type(type_name, context)
58
+ context.respond_to?(:types) ? context.types.type(type_name) : context.schema.get_type(type_name, context)
59
+ end
60
+
61
+ # Only object types that implement Node can have handed out an ID: root types, payloads and introspection
62
+ # types never did, so an ID naming them is not honoured (and their model is never looked up).
63
+ def node_type?(type)
64
+ type.respond_to?(:kind) && type.kind.object? &&
65
+ type.interfaces.any? { |interface| interface.graphql_name == "Node" }
66
+ end
67
+
68
+ def load(type, key, context)
69
+ return Authorization::Integration.find(type, nil, key, context) if type.respond_to?(:armory_model)
70
+
71
+ find&.call(type, key, context)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module GraphQL
7
+ module Armory
8
+ module PersistedQueries
9
+ # The operations a client build produced, loaded from Apollo's persisted query manifest or Relay's
10
+ # persisted output. Every id must be the digest of its query, so an edited manifest cannot put another
11
+ # query behind an id; every query is parsed once, here. Any problem raises at boot, and no message
12
+ # quotes the manifest's values: they may hold query text.
13
+ class Manifest
14
+ DIGESTS = { sha256: Digest::SHA256, sha1: Digest::SHA1, md5: Digest::MD5 }.freeze
15
+ APOLLO_FORMAT = "apollo-persisted-query-manifest"
16
+ ID_LIKE = /\A[A-Za-z0-9_.:-]{1,128}\z/
17
+
18
+ Operation = Struct.new(:id, :name, :body, :document, keyword_init: true)
19
+
20
+ def self.load(path, digest: :sha256)
21
+ data = begin
22
+ JSON.parse(File.read(path, mode: "r:bom|utf-8"))
23
+ rescue JSON::ParserError
24
+ # Not the parser's message: it quotes the file's content.
25
+ raise ConfigurationError, "#{path} is not valid JSON"
26
+ rescue SystemCallError => error
27
+ raise ConfigurationError, "#{path} cannot be read (#{error.class})"
28
+ end
29
+ new(data, digest: digest)
30
+ end
31
+
32
+ # `digest:` is how ids were made: :sha256 (Apollo), :md5 or :sha1 (Relay's options), or false for ids
33
+ # that are not digests (then nothing proves an id belongs to its query).
34
+ def initialize(data, digest: :sha256)
35
+ unless digest == false || DIGESTS.key?(digest)
36
+ raise ConfigurationError, "unknown digest #{digest.inspect}: use #{DIGESTS.keys.map(&:inspect).join(", ")} or false"
37
+ end
38
+
39
+ @digest = digest && DIGESTS.fetch(digest)
40
+ @operations = entries(data).each_with_index.with_object({}) do |((id, name, body), index), operations|
41
+ operation = operation(index, id, name, body)
42
+ raise ConfigurationError, "operation #{label(index, id)} appears twice in the manifest" if operations.key?(id)
43
+
44
+ operations[id] = operation
45
+ end.freeze
46
+ raise ConfigurationError, "the manifest holds no operations: is it the right file?" if @operations.empty?
47
+ end
48
+
49
+ def fetch(id)
50
+ @operations[id]
51
+ end
52
+
53
+ def size
54
+ @operations.size
55
+ end
56
+
57
+ private
58
+
59
+ # [id, name, body] triples from either format. A Relay map may have an operation keyed "format", so a
60
+ # Hash is Apollo's when its format says so, or when it has both "format" and "operations".
61
+ def entries(data)
62
+ raise ConfigurationError, unknown_format unless data.is_a?(Hash)
63
+ return apollo_entries(data) if data["format"] == APOLLO_FORMAT || (data.key?("format") && data.key?("operations"))
64
+
65
+ data.map { |id, body| [id, nil, body] }
66
+ end
67
+
68
+ def apollo_entries(data)
69
+ raise ConfigurationError, "unknown manifest format: expected #{APOLLO_FORMAT}" unless data["format"] == APOLLO_FORMAT
70
+ raise ConfigurationError, "unsupported manifest version: expected 1" unless data["version"].eql?(1)
71
+ raise ConfigurationError, "the manifest's operations must be an array" unless data["operations"].is_a?(Array)
72
+
73
+ data["operations"].each_with_index.map do |entry, index|
74
+ raise ConfigurationError, "manifest entry #{index} is not an object" unless entry.is_a?(Hash)
75
+
76
+ entry.values_at("id", "name", "body")
77
+ end
78
+ end
79
+
80
+ def operation(index, id, name, body)
81
+ unless id.is_a?(String) && body.is_a?(String) && (name.nil? || name.is_a?(String))
82
+ raise ConfigurationError, "manifest entry #{index} needs a String id, a String body and a String name or none"
83
+ end
84
+ if @digest && @digest.hexdigest(body) != id
85
+ raise ConfigurationError, "operation #{label(index, id)} is not the digest of its query: the manifest was edited, or `digest:` is wrong"
86
+ end
87
+
88
+ # Copies, so neither the caller's data nor a fetched operation can change what a later fetch returns.
89
+ Operation.new(id: id.dup.freeze, name: name&.dup&.freeze, body: body.dup.freeze, document: parse(label(index, id), body)).freeze
90
+ end
91
+
92
+ def parse(label, body)
93
+ GraphQL.parse(body)
94
+ rescue GraphQL::ParseError
95
+ raise ConfigurationError, "operation #{label} does not parse"
96
+ end
97
+
98
+ # The id names the operation in errors only when it looks like an id; otherwise it may be query text.
99
+ def label(index, id)
100
+ id.match?(ID_LIKE) ? id : "at entry #{index}"
101
+ end
102
+
103
+ def unknown_format
104
+ "unknown manifest format: expected Apollo's persisted query manifest or Relay's persisted output"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ # Persisted queries: clients send the id of an operation their build produced, and the server runs the
8
+ # stored query. With strict mode on, it refuses queries it does not hold.
9
+ #
10
+ # class AppSchema < GraphQL::Schema
11
+ # use GraphQL::Armory::PersistedQueries, store: Manifest.load("persisted-queries.json"), strict: true
12
+ # end
13
+ #
14
+ # AppSchema.execute(params[:query], context: { operation_id: PersistedQueries.operation_id(params) })
15
+ #
16
+ # Hooks only `Schema.query_class`: the stored document goes to the query as `document:`, so it is parsed once,
17
+ # at boot, and runs the same on both engines.
18
+ module PersistedQueries
19
+ class ConfigurationError < Armory::Error; end
20
+
21
+ UNKNOWN_OPERATION = "Unknown operation id"
22
+ MISMATCH = "The operation id and the query do not match"
23
+ RAW_QUERY_REFUSED = "Only persisted operations are allowed: send an operation id"
24
+
25
+ # What a refused request holds: nothing to run.
26
+ EMPTY_DOCUMENT = GraphQL::Language::Nodes::Document.new(definitions: [])
27
+
28
+ # Validates nothing and answers the refusal, through the query's public `static_validator:` option.
29
+ class Refusal < GraphQL::StaticValidation::Validator
30
+ def initialize(schema, message)
31
+ super(schema: schema)
32
+ @message = message
33
+ end
34
+
35
+ def validate(_query, **)
36
+ { errors: [GraphQL::StaticValidation::Error.new(@message)], remaining_timeout: nil }
37
+ end
38
+ end
39
+
40
+ # `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, or your own.
41
+ # `strict: true` refuses raw queries unless the context holds `armory_allow_raw_query: true`.
42
+ def self.use(schema, store:, strict: false)
43
+ if store.is_a?(Hash) || !store.respond_to?(:fetch)
44
+ raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
45
+ end
46
+ raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
47
+
48
+ if schema.singleton_class.include?(Guard)
49
+ raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
50
+ end
51
+
52
+ schema.instance_variable_set(:@armory_persisted_queries, [store, strict].freeze)
53
+ schema.singleton_class.prepend(Guard)
54
+ schema.query_class(schema.query_class)
55
+ end
56
+
57
+ # Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
58
+ module Guard
59
+ UNSET = Object.new.freeze
60
+
61
+ def query_class(new_query_class = UNSET)
62
+ return super() if UNSET.equal?(new_query_class)
63
+ return super(new_query_class) if new_query_class.include?(Lookup)
64
+
65
+ super(Class.new(new_query_class) { include Lookup })
66
+ end
67
+
68
+ def armory_persisted_queries
69
+ @armory_persisted_queries || superclass.armory_persisted_queries
70
+ end
71
+ end
72
+
73
+ # The operation id a request sent, from Rack or Rails params: `operationId`, `extensions.operationId` or
74
+ # Apollo's `extensions.persistedQuery.sha256Hash` (`extensions` may arrive as a JSON string on GET).
75
+ # Answers nil when there is none, or when it is not a non-empty String.
76
+ def self.operation_id(params)
77
+ params = plain(params)
78
+ extensions = params["extensions"]
79
+ extensions = parse_json(extensions) if extensions.is_a?(String)
80
+ extensions = plain(extensions)
81
+ persisted_query = plain(extensions["persistedQuery"])
82
+
83
+ [params["operationId"], extensions["operationId"], persisted_query["sha256Hash"]].find do |candidate|
84
+ candidate.is_a?(String) && !candidate.empty?
85
+ end
86
+ end
87
+
88
+ def self.plain(value)
89
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
90
+ value.is_a?(Hash) ? value : {}
91
+ end
92
+ private_class_method :plain
93
+
94
+ def self.parse_json(text)
95
+ JSON.parse(text)
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+ private_class_method :parse_json
100
+
101
+ # Swaps the request's query for the stored document, or for nothing with an error when it is refused.
102
+ module Lookup
103
+ def initialize(schema, query_string = nil, **options)
104
+ store, strict = schema.armory_persisted_queries
105
+ context = options[:context]
106
+ operation_id = context[:operation_id] if context.respond_to?(:[])
107
+ text = query_string || options[:query]
108
+ text = nil if text == ""
109
+
110
+ refusal, document =
111
+ if !operation_id.nil?
112
+ operation = operation_id.is_a?(String) ? store.fetch(operation_id) : nil
113
+ if operation.nil?
114
+ UNKNOWN_OPERATION
115
+ elsif options[:document] || (text && !same_query?(text, operation))
116
+ MISMATCH
117
+ else
118
+ [nil, operation.document]
119
+ end
120
+ elsif strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
121
+ RAW_QUERY_REFUSED
122
+ end
123
+
124
+ return super if refusal.nil? && document.nil?
125
+ return super(schema, nil, **options, query: nil, document: document) if refusal.nil?
126
+
127
+ # A refused request fails validation with the refusal as its only error, so neither engine runs it. Its
128
+ # text is never parsed; a document the app built itself is kept, for `Schema.validate`, which validates it
129
+ # with its own validator.
130
+ super(schema, nil, **options, query: nil, document: options[:document] || EMPTY_DOCUMENT, validate: true, static_validator: Refusal.new(schema, refusal))
131
+ end
132
+
133
+ private
134
+
135
+ # Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
136
+ def same_query?(text, operation)
137
+ text == operation.body || text == operation.document.to_query_string
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ require "graphql/armory/persisted_queries/manifest"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Armory
5
- VERSION = "0.1.0.pre2"
5
+ VERSION = "0.1.0.pre4"
6
6
  end
7
7
  end
@@ -10,4 +10,7 @@ module GraphQL
10
10
  end
11
11
  end
12
12
 
13
+ require "graphql/armory/encoder"
13
14
  require "graphql/armory/authorization"
15
+ require "graphql/armory/ids"
16
+ require "graphql/armory/persisted_queries"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-armory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre2
4
+ version: 0.1.0.pre4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Israel
@@ -41,6 +41,9 @@ files:
41
41
  - LICENSE
42
42
  - MIGRATING.md
43
43
  - README.md
44
+ - docs/authorization.md
45
+ - docs/encrypted-ids.md
46
+ - docs/persisted-queries.md
44
47
  - lib/graphql-armory.rb
45
48
  - lib/graphql/armory.rb
46
49
  - lib/graphql/armory/authorization.rb
@@ -72,6 +75,13 @@ files:
72
75
  - lib/graphql/armory/authorization/rule.rb
73
76
  - lib/graphql/armory/authorization/scope.rb
74
77
  - lib/graphql/armory/authorization/telemetry.rb
78
+ - lib/graphql/armory/encoder.rb
79
+ - lib/graphql/armory/encoder/default.rb
80
+ - lib/graphql/armory/encoder/legacy.rb
81
+ - lib/graphql/armory/encoder/versioned.rb
82
+ - lib/graphql/armory/ids.rb
83
+ - lib/graphql/armory/persisted_queries.rb
84
+ - lib/graphql/armory/persisted_queries/manifest.rb
75
85
  - lib/graphql/armory/version.rb
76
86
  - lib/tasks/graphql_armory.rake
77
87
  homepage: https://github.com/israelb/graphql-armory