graphql-armory 0.1.0.pre → 0.1.0.pre3
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 +29 -0
- data/LICENSE +0 -0
- data/MIGRATING.md +1 -1
- data/README.md +26 -266
- data/docs/authorization.md +264 -0
- data/docs/encrypted-ids.md +136 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/null.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/pundit.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/substitute.rb +0 -0
- data/lib/graphql/armory/authorization/audit/command.rb +0 -0
- data/lib/graphql/armory/authorization/audit/report.rb +0 -0
- data/lib/graphql/armory/authorization/audit.rb +53 -11
- data/lib/graphql/armory/authorization/authorize.rb +0 -0
- data/lib/graphql/armory/authorization/configuration.rb +0 -0
- data/lib/graphql/armory/authorization/controls/actor.rb +0 -0
- data/lib/graphql/armory/authorization/controls/context.rb +0 -0
- data/lib/graphql/armory/authorization/controls/rule.rb +0 -0
- data/lib/graphql/armory/authorization/controls/subject.rb +0 -0
- data/lib/graphql/armory/authorization/errors.rb +0 -0
- data/lib/graphql/armory/authorization/integration/argument.rb +0 -0
- data/lib/graphql/armory/authorization/integration/base.rb +3 -1
- data/lib/graphql/armory/authorization/integration/field.rb +1 -1
- data/lib/graphql/armory/authorization/integration/find_by.rb +0 -0
- data/lib/graphql/armory/authorization/integration/input_object.rb +31 -0
- data/lib/graphql/armory/authorization/integration/object.rb +0 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +1 -1
- data/lib/graphql/armory/authorization/integration/schema.rb +0 -0
- data/lib/graphql/armory/authorization/integration.rb +32 -0
- data/lib/graphql/armory/authorization/memo.rb +0 -0
- data/lib/graphql/armory/authorization/rspec.rb +0 -0
- data/lib/graphql/armory/authorization/rule.rb +0 -0
- data/lib/graphql/armory/authorization/scope.rb +0 -0
- data/lib/graphql/armory/authorization/telemetry.rb +0 -0
- data/lib/graphql/armory/authorization.rb +0 -0
- 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/version.rb +1 -1
- data/lib/graphql/armory.rb +2 -0
- data/lib/graphql-armory.rb +0 -0
- data/lib/tasks/graphql_armory.rake +0 -0
- metadata +13 -9
|
@@ -6,6 +6,7 @@ require "graphql/armory/authorization/integration/find_by"
|
|
|
6
6
|
require "graphql/armory/authorization/integration/field"
|
|
7
7
|
require "graphql/armory/authorization/integration/argument"
|
|
8
8
|
require "graphql/armory/authorization/integration/resolver"
|
|
9
|
+
require "graphql/armory/authorization/integration/input_object"
|
|
9
10
|
require "graphql/armory/authorization/integration/base"
|
|
10
11
|
|
|
11
12
|
module GraphQL
|
|
@@ -62,6 +63,37 @@ module GraphQL
|
|
|
62
63
|
record if record && allowed?(record, rule, context)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# Loads a `loads:` argument inside the user's scope. When the schema defines `object_from_id` (global
|
|
67
|
+
# IDs), it decodes the id; the record must be of the type's model, and is then looked up again
|
|
68
|
+
# through the scope, so a foreign id still looks like a missing one. Otherwise the id is the primary key.
|
|
69
|
+
def self.load(type, id, context)
|
|
70
|
+
schema = context.schema
|
|
71
|
+
return find(type, nil, id, context) unless decodes_ids?(schema) && type.respond_to?(:armory_model)
|
|
72
|
+
|
|
73
|
+
model = type.armory_model
|
|
74
|
+
record = decode(schema, id, context)
|
|
75
|
+
return nil unless record.is_a?(model)
|
|
76
|
+
|
|
77
|
+
find(type, nil, record.public_send(model.primary_key), context)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `GlobalID.find`, graphql-ruby's documented `object_from_id`, raises for a missing record, and a
|
|
81
|
+
# decoder may raise for a malformed id (ArgumentError, or the Encoder's InvalidError). All must answer like a
|
|
82
|
+
# record out of scope: nil.
|
|
83
|
+
def self.decode(schema, id, context)
|
|
84
|
+
schema.object_from_id(id, context)
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
raise unless error.is_a?(ArgumentError) || error.is_a?(Encoder::InvalidError) || error.class.name == "ActiveRecord::RecordNotFound"
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
private_class_method :decode
|
|
91
|
+
|
|
92
|
+
# graphql-ruby's default `object_from_id` only raises, so an override is the app decoding its IDs.
|
|
93
|
+
def self.decodes_ids?(schema)
|
|
94
|
+
schema.method(:object_from_id).owner != GraphQL::Schema.singleton_class
|
|
95
|
+
end
|
|
96
|
+
|
|
65
97
|
# An interface field with no rule of its own cannot name the type it is resolved on, so it is only
|
|
66
98
|
# covered when no implementer is a bypass: a field on a bypassed type is denied, and so is this one.
|
|
67
99
|
def self.bypassed_implementer?(interface, schema)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
data/lib/graphql/armory.rb
CHANGED
data/lib/graphql-armory.rb
CHANGED
|
File without changes
|
|
File without changes
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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.pre3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Israel
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: graphql
|
|
@@ -34,7 +33,6 @@ description: 'Open-source extensions for graphql-ruby built on its public extens
|
|
|
34
33
|
points. The first module is authorization: deny-by-default coverage, scoped lists
|
|
35
34
|
and connections, IDOR-safe lookups, mutations, interfaces and unions, visibility
|
|
36
35
|
driven by the policy, and a schema audit for CI.'
|
|
37
|
-
email:
|
|
38
36
|
executables: []
|
|
39
37
|
extensions: []
|
|
40
38
|
extra_rdoc_files: []
|
|
@@ -43,6 +41,8 @@ files:
|
|
|
43
41
|
- LICENSE
|
|
44
42
|
- MIGRATING.md
|
|
45
43
|
- README.md
|
|
44
|
+
- docs/authorization.md
|
|
45
|
+
- docs/encrypted-ids.md
|
|
46
46
|
- lib/graphql-armory.rb
|
|
47
47
|
- lib/graphql/armory.rb
|
|
48
48
|
- lib/graphql/armory/authorization.rb
|
|
@@ -65,6 +65,7 @@ files:
|
|
|
65
65
|
- lib/graphql/armory/authorization/integration/base.rb
|
|
66
66
|
- lib/graphql/armory/authorization/integration/field.rb
|
|
67
67
|
- lib/graphql/armory/authorization/integration/find_by.rb
|
|
68
|
+
- lib/graphql/armory/authorization/integration/input_object.rb
|
|
68
69
|
- lib/graphql/armory/authorization/integration/object.rb
|
|
69
70
|
- lib/graphql/armory/authorization/integration/resolver.rb
|
|
70
71
|
- lib/graphql/armory/authorization/integration/schema.rb
|
|
@@ -73,6 +74,11 @@ files:
|
|
|
73
74
|
- lib/graphql/armory/authorization/rule.rb
|
|
74
75
|
- lib/graphql/armory/authorization/scope.rb
|
|
75
76
|
- lib/graphql/armory/authorization/telemetry.rb
|
|
77
|
+
- lib/graphql/armory/encoder.rb
|
|
78
|
+
- lib/graphql/armory/encoder/default.rb
|
|
79
|
+
- lib/graphql/armory/encoder/legacy.rb
|
|
80
|
+
- lib/graphql/armory/encoder/versioned.rb
|
|
81
|
+
- lib/graphql/armory/ids.rb
|
|
76
82
|
- lib/graphql/armory/version.rb
|
|
77
83
|
- lib/tasks/graphql_armory.rake
|
|
78
84
|
homepage: https://github.com/israelb/graphql-armory
|
|
@@ -84,7 +90,6 @@ metadata:
|
|
|
84
90
|
changelog_uri: https://github.com/israelb/graphql-armory/blob/main/CHANGELOG.md
|
|
85
91
|
bug_tracker_uri: https://github.com/israelb/graphql-armory/issues
|
|
86
92
|
rubygems_mfa_required: 'true'
|
|
87
|
-
post_install_message:
|
|
88
93
|
rdoc_options: []
|
|
89
94
|
require_paths:
|
|
90
95
|
- lib
|
|
@@ -95,12 +100,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
95
100
|
version: '3.1'
|
|
96
101
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
102
|
requirements:
|
|
98
|
-
- - "
|
|
103
|
+
- - ">="
|
|
99
104
|
- !ruby/object:Gem::Version
|
|
100
|
-
version:
|
|
105
|
+
version: '0'
|
|
101
106
|
requirements: []
|
|
102
|
-
rubygems_version: 3.
|
|
103
|
-
signing_key:
|
|
107
|
+
rubygems_version: 3.6.9
|
|
104
108
|
specification_version: 4
|
|
105
109
|
summary: 'Authorization for graphql-ruby: Pundit and CanCanCan, deny-by-default, with
|
|
106
110
|
a schema audit.'
|