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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/MIGRATING.md +1 -1
- data/README.md +21 -274
- data/docs/authorization.md +264 -0
- data/docs/encrypted-ids.md +136 -0
- data/docs/persisted-queries.md +184 -0
- data/lib/graphql/armory/authorization/integration.rb +3 -2
- data/lib/graphql/armory/encoder/default.rb +59 -0
- data/lib/graphql/armory/encoder/legacy.rb +34 -0
- data/lib/graphql/armory/encoder/versioned.rb +67 -0
- data/lib/graphql/armory/encoder.rb +137 -0
- data/lib/graphql/armory/ids.rb +76 -0
- data/lib/graphql/armory/persisted_queries/manifest.rb +109 -0
- data/lib/graphql/armory/persisted_queries.rb +144 -0
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +3 -0
- metadata +11 -1
|
@@ -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"
|
data/lib/graphql/armory.rb
CHANGED
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.
|
|
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
|