graphql-armory 0.1.0.pre
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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/MIGRATING.md +85 -0
- data/README.md +311 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +92 -0
- data/lib/graphql/armory/authorization/adapters/null.rb +28 -0
- data/lib/graphql/armory/authorization/adapters/pundit.rb +83 -0
- data/lib/graphql/armory/authorization/adapters/substitute.rb +46 -0
- data/lib/graphql/armory/authorization/audit/command.rb +36 -0
- data/lib/graphql/armory/authorization/audit/report.rb +51 -0
- data/lib/graphql/armory/authorization/audit.rb +213 -0
- data/lib/graphql/armory/authorization/authorize.rb +68 -0
- data/lib/graphql/armory/authorization/configuration.rb +35 -0
- data/lib/graphql/armory/authorization/controls/actor.rb +15 -0
- data/lib/graphql/armory/authorization/controls/context.rb +29 -0
- data/lib/graphql/armory/authorization/controls/rule.rb +15 -0
- data/lib/graphql/armory/authorization/controls/subject.rb +15 -0
- data/lib/graphql/armory/authorization/errors.rb +29 -0
- data/lib/graphql/armory/authorization/integration/argument.rb +74 -0
- data/lib/graphql/armory/authorization/integration/base.rb +34 -0
- data/lib/graphql/armory/authorization/integration/field.rb +102 -0
- data/lib/graphql/armory/authorization/integration/find_by.rb +22 -0
- data/lib/graphql/armory/authorization/integration/object.rb +67 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +84 -0
- data/lib/graphql/armory/authorization/integration/schema.rb +60 -0
- data/lib/graphql/armory/authorization/integration.rb +104 -0
- data/lib/graphql/armory/authorization/memo.rb +35 -0
- data/lib/graphql/armory/authorization/rspec.rb +21 -0
- data/lib/graphql/armory/authorization/rule.rb +61 -0
- data/lib/graphql/armory/authorization/scope.rb +37 -0
- data/lib/graphql/armory/authorization/telemetry.rb +42 -0
- data/lib/graphql/armory/authorization.rb +41 -0
- data/lib/graphql/armory/version.rb +7 -0
- data/lib/graphql/armory.rb +13 -0
- data/lib/graphql-armory.rb +2 -0
- data/lib/tasks/graphql_armory.rake +11 -0
- metadata +107 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Included in a field class by `include GraphQL::Armory::Authorization::Base`.
|
|
8
|
+
#
|
|
9
|
+
# field :notes, String, authorize: :manage # another action on the parent
|
|
10
|
+
# field :id, ID, authorize: :none, reason: "ids are public" # declared bypass
|
|
11
|
+
# field :owner_id, ID, authorize: true # the parent type's own action
|
|
12
|
+
# field :notes, String, authorize: :manage, visible_when: :export # hidden from users who cannot export
|
|
13
|
+
#
|
|
14
|
+
# A field with no rule is covered by its type's rule, unless the type is bypassed:
|
|
15
|
+
# coverage does not propagate from a bypass, so that field is denied.
|
|
16
|
+
#
|
|
17
|
+
# graphql-ruby hands `authorized?` the application object, not the type instance, so the type
|
|
18
|
+
# whose rule applies is the one that owns the field.
|
|
19
|
+
module Field
|
|
20
|
+
# `find_by: :id` looks the object up inside the user's scope instead of calling the resolver.
|
|
21
|
+
# `loads:` arguments also load through the scope unless `load_through_scope: false`.
|
|
22
|
+
def initialize(*args, authorize: nil, reason: nil, find_by: nil, load_through_scope: true, visible_when: nil, **kwargs, &block)
|
|
23
|
+
@armory_declared = authorize
|
|
24
|
+
@armory_reason = reason
|
|
25
|
+
@armory_visible_when = visible_when&.to_sym
|
|
26
|
+
@armory_load_through_scope = load_through_scope
|
|
27
|
+
Rule.none(reason: reason) if authorize == :none
|
|
28
|
+
super(*args, **kwargs, &block)
|
|
29
|
+
extension(Integration::FindBy, key: find_by) if find_by
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :armory_declared, :armory_reason, :armory_visible_when
|
|
33
|
+
|
|
34
|
+
def armory_load_through_scope?
|
|
35
|
+
@armory_load_through_scope
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def object_from_id(type, id, context)
|
|
39
|
+
return super unless @armory_load_through_scope
|
|
40
|
+
|
|
41
|
+
Integration.find(type, nil, id, context)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A `loads:` id that is not found is answered like a denial, so the client cannot tell the two apart.
|
|
45
|
+
def load_application_object_failed(error)
|
|
46
|
+
return super unless @armory_load_through_scope
|
|
47
|
+
|
|
48
|
+
raise GraphQL::UnauthorizedError.new(object: nil, type: error.argument.loads, context: error.context)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# `visible_when: :export` hides the field from users the owner's policy answers `false` for.
|
|
53
|
+
def visible?(context)
|
|
54
|
+
return super unless @armory_visible_when
|
|
55
|
+
|
|
56
|
+
unless owner.respond_to?(:armory_model)
|
|
57
|
+
raise ConfigurationError, "#{owner.graphql_name}.#{original_name} uses visible_when:, so its type must include Base"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
rule = Rule.new(@armory_visible_when, attribute: original_name)
|
|
61
|
+
super && Integration.visible?(owner.armory_model, rule, context)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def authorized?(object, args, context)
|
|
65
|
+
rule = armory_rule_for(owner, context)
|
|
66
|
+
return false if rule.nil?
|
|
67
|
+
|
|
68
|
+
(rule == :covered || Integration.allowed?(object, rule, context)) && super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# A resolver or mutation that includes Base enforces its own rule, so the field is covered by it.
|
|
74
|
+
def armory_resolver?
|
|
75
|
+
resolver.respond_to?(:armory_rule)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A Rule to check on the parent object, :covered when the type's own check already did it,
|
|
79
|
+
# or nil to deny.
|
|
80
|
+
def armory_rule_for(type, context)
|
|
81
|
+
type_rule = type.armory_rule if type.respond_to?(:armory_rule)
|
|
82
|
+
|
|
83
|
+
case @armory_declared
|
|
84
|
+
when nil
|
|
85
|
+
return :covered if armory_resolver?
|
|
86
|
+
return Integration.bypassed_implementer?(type, context.schema) ? nil : :covered if type.respond_to?(:kind) && type.kind.interface?
|
|
87
|
+
return :covered unless type.respond_to?(:armory_rule)
|
|
88
|
+
|
|
89
|
+
type_rule && (!type_rule.none? || type_rule.covers_fields?) ? :covered : nil
|
|
90
|
+
when true
|
|
91
|
+
type_rule unless type_rule&.none?
|
|
92
|
+
when :none
|
|
93
|
+
Rule.none(reason: @armory_reason)
|
|
94
|
+
else
|
|
95
|
+
Rule.new(@armory_declared, attribute: original_name)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Field extension behind `field :invoice, Types::Invoice, find_by: :id`.
|
|
8
|
+
# It never calls the field's own resolver: the object is looked up inside the user's scope,
|
|
9
|
+
# so one the user may not see comes back exactly like one that does not exist.
|
|
10
|
+
class FindBy < GraphQL::Schema::FieldExtension
|
|
11
|
+
# The interpreter resolves one object at a time, Execution::Next a batch of them.
|
|
12
|
+
def resolve(arguments:, context:, object: nil, objects: nil)
|
|
13
|
+
key = options[:key]
|
|
14
|
+
found = Integration.find(field.type.unwrap, key, arguments[key], context)
|
|
15
|
+
|
|
16
|
+
objects ? Array.new(objects.size, found) : found
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Extended onto an object type class by `include GraphQL::Armory::Authorization::Base`.
|
|
8
|
+
module Object
|
|
9
|
+
# `default_action :read` makes every type below be checked with `read` unless it declares its own.
|
|
10
|
+
def default_action(action = nil)
|
|
11
|
+
return @armory_default_action = action if action
|
|
12
|
+
|
|
13
|
+
@armory_default_action || (superclass.default_action if superclass.respond_to?(:default_action))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authorize(action, reason: nil, **options)
|
|
17
|
+
@armory_rule = Rule.new(action, reason: reason, **options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Own rule, then an inherited one, then the default action. Nil means: nothing covers this type.
|
|
21
|
+
def armory_rule
|
|
22
|
+
return @armory_rule if @armory_rule
|
|
23
|
+
|
|
24
|
+
inherited = superclass.armory_rule if superclass.respond_to?(:armory_rule)
|
|
25
|
+
return inherited if inherited
|
|
26
|
+
|
|
27
|
+
action = default_action
|
|
28
|
+
Rule.new(action) if action
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The model the type wraps: `authorize :read, model: Invoice`, else the constant named like the type.
|
|
32
|
+
def armory_model
|
|
33
|
+
declared = armory_rule&.model
|
|
34
|
+
return declared if declared.is_a?(Class)
|
|
35
|
+
|
|
36
|
+
Object.const_get(declared || name&.split("::")&.last || graphql_name)
|
|
37
|
+
rescue NameError
|
|
38
|
+
raise ConfigurationError,
|
|
39
|
+
"cannot tell which model #{graphql_name} wraps: declare it with `authorize :read, model: YourModel`"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Runs before pagination, so connections page and count the scoped list.
|
|
43
|
+
def scope_items(items, context)
|
|
44
|
+
super(Integration.scoped(items, armory_rule, context), context)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# `authorize :read, visible_when: :export` hides the type from users the policy answers `false`
|
|
49
|
+
# for, in introspection and in error suggestions. Asked of the model's class, with no record.
|
|
50
|
+
def visible?(context)
|
|
51
|
+
rule = armory_rule
|
|
52
|
+
return super unless rule&.visible_when
|
|
53
|
+
|
|
54
|
+
super && Integration.visible?(armory_model, Rule.new(rule.visible_when, policy: rule.policy), context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def authorized?(object, context)
|
|
58
|
+
rule = armory_rule
|
|
59
|
+
return false if rule.nil?
|
|
60
|
+
|
|
61
|
+
Integration.allowed?(object, rule, context) && super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Class-level DSL of a resolver or mutation that includes `GraphQL::Armory::Authorization::Base`.
|
|
8
|
+
#
|
|
9
|
+
# class ApproveInvoice < BaseMutation
|
|
10
|
+
# authorize :approve, model: Invoice # InvoicePolicy#approve? on the loaded record
|
|
11
|
+
# authorize :approve, model: Invoice, on_denied: :errors
|
|
12
|
+
# authorize :none, reason: "anyone may call it"
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# A mutation is an action, not a read: `default_action` does not apply, and one with no rule is denied.
|
|
16
|
+
module ResolverClass
|
|
17
|
+
ON_DENIED = %i[default errors].freeze
|
|
18
|
+
|
|
19
|
+
# `on_denied: :default` follows the schema's `on_unauthorized` mode, like a field. `:errors` answers
|
|
20
|
+
# `{ errors: [{ message: "Not allowed", code: "FORBIDDEN" }] }` as data: the payload needs an `errors` field.
|
|
21
|
+
def authorize(action, reason: nil, on_denied: :default, **options)
|
|
22
|
+
raise ArgumentError, "on_denied must be one of #{ON_DENIED.inspect}, got #{on_denied.inspect}" unless ON_DENIED.include?(on_denied)
|
|
23
|
+
|
|
24
|
+
@armory_on_denied = on_denied
|
|
25
|
+
@armory_rule = Rule.new(action, reason: reason, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def armory_rule
|
|
29
|
+
@armory_rule || (superclass.armory_rule if superclass.respond_to?(:armory_rule))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def armory_on_denied
|
|
33
|
+
@armory_on_denied || (superclass.armory_on_denied if superclass.respond_to?(:armory_on_denied)) || :default
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The model the rule is asked about, which a mutation cannot be named after.
|
|
37
|
+
def armory_model
|
|
38
|
+
model = armory_rule&.model
|
|
39
|
+
return model if model.is_a?(Class)
|
|
40
|
+
|
|
41
|
+
raise ConfigurationError, "#{graphql_name}: declare the model with `authorize :action, model: YourModel`"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Instance side: runs after the `loads:` arguments are loaded, so the policy sees the loaded record.
|
|
46
|
+
module Resolver
|
|
47
|
+
# Every loaded record of the model is asked about; with none loaded, the model's class is.
|
|
48
|
+
def authorized?(**inputs)
|
|
49
|
+
rule = self.class.armory_rule
|
|
50
|
+
return armory_denied if rule.nil?
|
|
51
|
+
|
|
52
|
+
allowed = armory_subjects(rule, inputs).all? { |subject| Integration.allowed?(subject, rule, context) }
|
|
53
|
+
allowed ? super : armory_denied
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `loads:` arguments are looked up inside the user's scope, so a foreign id answers like a missing one.
|
|
57
|
+
def object_from_id(type, id, context)
|
|
58
|
+
Integration.find(type, nil, id, context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_application_object_failed(error)
|
|
62
|
+
raise GraphQL::UnauthorizedError.new(object: nil, type: error.argument.loads, context: error.context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def armory_subjects(rule, inputs)
|
|
68
|
+
return [nil] if rule.none?
|
|
69
|
+
|
|
70
|
+
model = self.class.armory_model
|
|
71
|
+
loaded = inputs.values.flatten.select { |value| value.is_a?(model) }
|
|
72
|
+
loaded.empty? ? [model] : loaded
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def armory_denied
|
|
76
|
+
return false unless self.class.armory_on_denied == :errors
|
|
77
|
+
|
|
78
|
+
[false, { errors: [{ message: "Not allowed", code: "FORBIDDEN" }] }]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Extended onto a schema class by `use GraphQL::Armory::Authorization`.
|
|
8
|
+
# Both engines route every denial through these hooks, so the client sees the same thing in each.
|
|
9
|
+
module Schema
|
|
10
|
+
def armory_configuration=(configuration)
|
|
11
|
+
@armory_configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def armory_configuration
|
|
15
|
+
@armory_configuration ||
|
|
16
|
+
(superclass.armory_configuration if superclass.respond_to?(:armory_configuration))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `loads:` asks the schema which type an object is. For an object type the answer is known:
|
|
20
|
+
# the lookup went through that type's model. Abstract types and foreign objects are the app's call.
|
|
21
|
+
def resolve_type(type, object, context)
|
|
22
|
+
if !type.kind.abstract? && type.respond_to?(:armory_model) && object.is_a?(type.armory_model)
|
|
23
|
+
type
|
|
24
|
+
else
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unauthorized_object(error)
|
|
30
|
+
raise_denial(error)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A denied field that cannot be null must still fail: Execution::Next would otherwise leave
|
|
35
|
+
# a null in a non-null position without an error or a null propagation.
|
|
36
|
+
def unauthorized_field(error)
|
|
37
|
+
raise_denial(error)
|
|
38
|
+
raise_not_found if error.field&.type&.non_null?
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def raise_denial(error)
|
|
46
|
+
case armory_configuration.on_unauthorized
|
|
47
|
+
when :error then raise_not_found
|
|
48
|
+
when :raise then raise NotAuthorizedError, error.type&.graphql_name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The same answer a missing object gets, so a denial does not reveal that it exists.
|
|
53
|
+
def raise_not_found
|
|
54
|
+
raise GraphQL::ExecutionError.new("Not found", extensions: { "code" => "NOT_FOUND" })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql/armory/authorization/integration/schema"
|
|
4
|
+
require "graphql/armory/authorization/integration/object"
|
|
5
|
+
require "graphql/armory/authorization/integration/find_by"
|
|
6
|
+
require "graphql/armory/authorization/integration/field"
|
|
7
|
+
require "graphql/armory/authorization/integration/argument"
|
|
8
|
+
require "graphql/armory/authorization/integration/resolver"
|
|
9
|
+
require "graphql/armory/authorization/integration/base"
|
|
10
|
+
|
|
11
|
+
module GraphQL
|
|
12
|
+
module Armory
|
|
13
|
+
module Authorization
|
|
14
|
+
# Translates graphql-ruby's hooks into questions for the core. No permission logic lives here.
|
|
15
|
+
module Integration
|
|
16
|
+
# In graphql 2.5.26 to 2.6.1 Execution::Next resolves a denied field against a nil object and crashes.
|
|
17
|
+
NEXT_MINIMUM = Gem::Version.new("2.6.2")
|
|
18
|
+
NEXT_SUPPORTED = Gem::Version.new(GraphQL::VERSION) >= NEXT_MINIMUM
|
|
19
|
+
|
|
20
|
+
def self.configuration(context)
|
|
21
|
+
check_engine!(context)
|
|
22
|
+
|
|
23
|
+
schema = context.schema
|
|
24
|
+
return schema.armory_configuration if schema.respond_to?(:armory_configuration)
|
|
25
|
+
|
|
26
|
+
raise ConfigurationError,
|
|
27
|
+
"this schema never installed the plugin: add `use GraphQL::Armory::Authorization, adapter: ...` to #{schema}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.check_engine!(context, supported: NEXT_SUPPORTED)
|
|
31
|
+
return if supported || !context[:__graphql_execute_next]
|
|
32
|
+
|
|
33
|
+
raise ConfigurationError,
|
|
34
|
+
"Execution::Next needs graphql >= #{NEXT_MINIMUM} (running #{GraphQL::VERSION}): " \
|
|
35
|
+
"a denied field crashes it in earlier versions"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Narrows a list to what the user may see. Without a rule nothing is visible.
|
|
39
|
+
def self.scoped(items, rule, context)
|
|
40
|
+
return Adapters::Null.new.scope(nil, items, rule) if rule.nil?
|
|
41
|
+
|
|
42
|
+
configuration = configuration(context)
|
|
43
|
+
Scope.(items, rule, context, adapter: configuration.adapter, sinks: configuration.sinks)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Looks an object up inside the user's scope, by primary key unless a key is given.
|
|
47
|
+
# What is out of scope and what does not exist look the same: nil.
|
|
48
|
+
def self.find(type, key, value, context)
|
|
49
|
+
unless type.respond_to?(:armory_model)
|
|
50
|
+
raise ConfigurationError,
|
|
51
|
+
"#{type.graphql_name} cannot be looked up through a scope: " \
|
|
52
|
+
"include GraphQL::Armory::Authorization::Base in its base class"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
model = type.armory_model
|
|
56
|
+
rule = type.armory_rule
|
|
57
|
+
conditions = { (key || model.primary_key) => value }
|
|
58
|
+
return scoped(model.all, rule, context).find_by(conditions) unless rule&.scope == :in_memory
|
|
59
|
+
|
|
60
|
+
# An in-memory scope yields an Array, which cannot look one record up: find it, then ask the rule.
|
|
61
|
+
record = model.find_by(conditions)
|
|
62
|
+
record if record && allowed?(record, rule, context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# An interface field with no rule of its own cannot name the type it is resolved on, so it is only
|
|
66
|
+
# covered when no implementer is a bypass: a field on a bypassed type is denied, and so is this one.
|
|
67
|
+
def self.bypassed_implementer?(interface, schema)
|
|
68
|
+
schema.possible_types(interface).any? do |type|
|
|
69
|
+
rule = type.armory_rule if type.respond_to?(:armory_rule)
|
|
70
|
+
rule && rule.none? && !rule.covers_fields?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One Authorize per query: building it (and its memo and telemetry) once per object is pure overhead.
|
|
75
|
+
def self.allowed?(subject, rule, context)
|
|
76
|
+
store = context.namespace(:armory)
|
|
77
|
+
authorize = store[:authorize] ||= begin
|
|
78
|
+
configuration = configuration(context)
|
|
79
|
+
Authorize.build(context, adapter: configuration.adapter, sinks: configuration.sinks)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
authorize.(subject, rule, context)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether a type or field may appear in the schema this user sees. The question is asked of the
|
|
86
|
+
# model's class, with no record, so the rule's action must be one a policy can answer that way.
|
|
87
|
+
# `Schema.types` with no context sees the whole schema. A schema dump asks for it explicitly with
|
|
88
|
+
# `to_definition(context: { armory_full_schema: true })`: graphql 2.6 builds an ordinary context for it,
|
|
89
|
+
# which cannot be told from an anonymous request, and an anonymous request must not see hidden members.
|
|
90
|
+
def self.visible?(model, rule, context)
|
|
91
|
+
unless context.respond_to?(:namespace)
|
|
92
|
+
raise ConfigurationError,
|
|
93
|
+
"visible_when: needs a per-query context; static Schema::Visibility profiles are not supported, " \
|
|
94
|
+
"use `dynamic: true` or no profiles"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return true if context.is_a?(GraphQL::Query::NullContext) || context[:armory_full_schema] == true
|
|
98
|
+
|
|
99
|
+
allowed?(model, rule, context)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# Remembers answers for the length of a request, so the same question is not asked twice.
|
|
7
|
+
class Memo
|
|
8
|
+
def self.build(context)
|
|
9
|
+
store = context.namespace(:armory)
|
|
10
|
+
store[:memo] ||= new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
# Keyed by identity: two equal-looking records are different subjects, and holding the
|
|
15
|
+
# subject as a key keeps it alive, so an object id can never be recycled for another one.
|
|
16
|
+
@answers = {}.compare_by_identity
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(subject, rule)
|
|
20
|
+
answers = (@answers[subject] ||= {})
|
|
21
|
+
return answers[rule] if answers.key?(rule)
|
|
22
|
+
|
|
23
|
+
answers[rule] = yield
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Never remembers: every question runs.
|
|
27
|
+
class Substitute
|
|
28
|
+
def fetch(_subject, _rule)
|
|
29
|
+
yield
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
# `require "graphql/armory/authorization/rspec"` in your spec helper, then:
|
|
6
|
+
#
|
|
7
|
+
# expect(AppSchema).to be_fully_authorized
|
|
8
|
+
RSpec::Matchers.define :be_fully_authorized do
|
|
9
|
+
match do |schema|
|
|
10
|
+
@report = GraphQL::Armory::Authorization::Audit.(schema)
|
|
11
|
+
@report.ok?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
failure_message do |schema|
|
|
15
|
+
"expected #{schema} to be fully authorized, but the audit found:\n#{@report}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failure_message_when_negated do |schema|
|
|
19
|
+
"expected #{schema} not to be fully authorized, but the audit found no problems"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# Immutable value holding what was declared, e.g. `authorize :read`.
|
|
7
|
+
# Each adapter reads the parts it needs (policy, subject, attribute, model, scope).
|
|
8
|
+
class Rule
|
|
9
|
+
attr_reader :action, :policy, :subject, :attribute, :model, :scope, :visible_when, :covers_fields, :reason
|
|
10
|
+
|
|
11
|
+
def self.none(reason:)
|
|
12
|
+
new(:none, reason: reason)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(action, policy: nil, subject: nil, attribute: nil, model: nil, scope: nil, visible_when: nil, covers_fields: false, reason: nil)
|
|
16
|
+
raise ArgumentError, "a rule needs an action" if action.nil?
|
|
17
|
+
|
|
18
|
+
@action = action.to_sym
|
|
19
|
+
@policy = policy
|
|
20
|
+
@subject = subject
|
|
21
|
+
@attribute = attribute
|
|
22
|
+
@model = model
|
|
23
|
+
@scope = scope
|
|
24
|
+
@visible_when = visible_when&.to_sym
|
|
25
|
+
@covers_fields = covers_fields
|
|
26
|
+
@reason = reason
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "a bypass needs a reason" if none? && reason.to_s.strip.empty?
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "covers_fields only applies to a bypass (`authorize :none`)" if covers_fields && !none?
|
|
31
|
+
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A bypassed type whose fields are covered by something else, such as the payload of a mutation.
|
|
36
|
+
def covers_fields?
|
|
37
|
+
covers_fields
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def none?
|
|
41
|
+
action == :none
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ==(other)
|
|
45
|
+
other.is_a?(Rule) && members == other.members
|
|
46
|
+
end
|
|
47
|
+
alias eql? ==
|
|
48
|
+
|
|
49
|
+
def hash
|
|
50
|
+
members.hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
def members
|
|
56
|
+
[action, policy, subject, attribute, model, scope, visible_when, covers_fields, reason]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# Narrows a list to what the actor may see. A declared bypass leaves the list alone.
|
|
7
|
+
class Scope
|
|
8
|
+
attr_reader :adapter, :telemetry
|
|
9
|
+
|
|
10
|
+
# `new` is inert: with no configuration every list is scoped down to nothing.
|
|
11
|
+
def initialize(adapter: Adapters::Null.new, telemetry: Telemetry.new)
|
|
12
|
+
@adapter = adapter
|
|
13
|
+
@telemetry = telemetry
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.build(adapter:, sinks: [])
|
|
17
|
+
new(adapter: adapter, telemetry: Telemetry.build(sinks: sinks))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.call(items, rule, context, adapter:, sinks: [])
|
|
21
|
+
build(adapter: adapter, sinks: sinks).(items, rule, context)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(items, rule, context)
|
|
25
|
+
if rule.none?
|
|
26
|
+
telemetry.record(:bypassed, reason: rule.reason)
|
|
27
|
+
return items
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
scoped = adapter.scope(adapter.actor(context), items, rule)
|
|
31
|
+
telemetry.record(:scoped, action: rule.action)
|
|
32
|
+
scoped
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# Records what happened (allowed, denied, bypassed, scoped) and hands it to its sinks.
|
|
7
|
+
# A sink is anything that responds to `call(signal, data)`. Never put record attributes in `data`.
|
|
8
|
+
class Telemetry
|
|
9
|
+
def self.build(sinks: [])
|
|
10
|
+
new(sinks: sinks)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(sinks: [])
|
|
14
|
+
@sinks = sinks
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record(signal, **data)
|
|
18
|
+
@sinks.each { |sink| sink.call(signal, data) }
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Keeps what was recorded, for tests.
|
|
23
|
+
class Substitute
|
|
24
|
+
attr_reader :recorded
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@recorded = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def record(signal, **data)
|
|
31
|
+
@recorded << [signal, data]
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def recorded?(signal)
|
|
36
|
+
@recorded.any? { |recorded_signal, _| recorded_signal == signal }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql/armory/authorization/errors"
|
|
4
|
+
require "graphql/armory/authorization/configuration"
|
|
5
|
+
require "graphql/armory/authorization/rule"
|
|
6
|
+
require "graphql/armory/authorization/memo"
|
|
7
|
+
require "graphql/armory/authorization/telemetry"
|
|
8
|
+
require "graphql/armory/authorization/adapters/null"
|
|
9
|
+
require "graphql/armory/authorization/adapters/substitute"
|
|
10
|
+
require "graphql/armory/authorization/authorize"
|
|
11
|
+
require "graphql/armory/authorization/scope"
|
|
12
|
+
require "graphql/armory/authorization/audit"
|
|
13
|
+
require "graphql/armory/authorization/controls/rule"
|
|
14
|
+
require "graphql/armory/authorization/controls/context"
|
|
15
|
+
require "graphql/armory/authorization/controls/actor"
|
|
16
|
+
require "graphql/armory/authorization/controls/subject"
|
|
17
|
+
require "graphql/armory/authorization/integration"
|
|
18
|
+
|
|
19
|
+
module GraphQL
|
|
20
|
+
module Armory
|
|
21
|
+
module Authorization
|
|
22
|
+
module Adapters
|
|
23
|
+
# Loaded on first use, so only the library behind the adapter you pick is required.
|
|
24
|
+
autoload :Pundit, "graphql/armory/authorization/adapters/pundit"
|
|
25
|
+
autoload :CanCan, "graphql/armory/authorization/adapters/cancan"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deny-by-default authorization. The core (Authorize, Scope) knows nothing about
|
|
30
|
+
# Pundit or CanCanCan: it asks an adapter four questions and trusts only a clear answer.
|
|
31
|
+
module Authorization
|
|
32
|
+
# The schema plugin: `use GraphQL::Armory::Authorization, adapter: :pundit`.
|
|
33
|
+
# Options: `adapter:` (:pundit or an adapter object), `on_unauthorized:` (:null, :error or :raise),
|
|
34
|
+
# `sinks:` (callables that receive each telemetry signal).
|
|
35
|
+
def self.use(schema_defn, **options)
|
|
36
|
+
schema_defn.extend(Integration::Schema)
|
|
37
|
+
schema_defn.armory_configuration = Configuration.new(**options)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|