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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE +21 -0
  4. data/MIGRATING.md +85 -0
  5. data/README.md +311 -0
  6. data/lib/graphql/armory/authorization/adapters/cancan.rb +92 -0
  7. data/lib/graphql/armory/authorization/adapters/null.rb +28 -0
  8. data/lib/graphql/armory/authorization/adapters/pundit.rb +83 -0
  9. data/lib/graphql/armory/authorization/adapters/substitute.rb +46 -0
  10. data/lib/graphql/armory/authorization/audit/command.rb +36 -0
  11. data/lib/graphql/armory/authorization/audit/report.rb +51 -0
  12. data/lib/graphql/armory/authorization/audit.rb +213 -0
  13. data/lib/graphql/armory/authorization/authorize.rb +68 -0
  14. data/lib/graphql/armory/authorization/configuration.rb +35 -0
  15. data/lib/graphql/armory/authorization/controls/actor.rb +15 -0
  16. data/lib/graphql/armory/authorization/controls/context.rb +29 -0
  17. data/lib/graphql/armory/authorization/controls/rule.rb +15 -0
  18. data/lib/graphql/armory/authorization/controls/subject.rb +15 -0
  19. data/lib/graphql/armory/authorization/errors.rb +29 -0
  20. data/lib/graphql/armory/authorization/integration/argument.rb +74 -0
  21. data/lib/graphql/armory/authorization/integration/base.rb +34 -0
  22. data/lib/graphql/armory/authorization/integration/field.rb +102 -0
  23. data/lib/graphql/armory/authorization/integration/find_by.rb +22 -0
  24. data/lib/graphql/armory/authorization/integration/object.rb +67 -0
  25. data/lib/graphql/armory/authorization/integration/resolver.rb +84 -0
  26. data/lib/graphql/armory/authorization/integration/schema.rb +60 -0
  27. data/lib/graphql/armory/authorization/integration.rb +104 -0
  28. data/lib/graphql/armory/authorization/memo.rb +35 -0
  29. data/lib/graphql/armory/authorization/rspec.rb +21 -0
  30. data/lib/graphql/armory/authorization/rule.rb +61 -0
  31. data/lib/graphql/armory/authorization/scope.rb +37 -0
  32. data/lib/graphql/armory/authorization/telemetry.rb +42 -0
  33. data/lib/graphql/armory/authorization.rb +41 -0
  34. data/lib/graphql/armory/version.rb +7 -0
  35. data/lib/graphql/armory.rb +13 -0
  36. data/lib/graphql-armory.rb +2 -0
  37. data/lib/tasks/graphql_armory.rake +11 -0
  38. 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