diff --git a/README.md b/README.md index dce9941..87d1fe9 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,26 @@ end If the lambda returns a falsy value or raises a `Pundit::UnauthorizedError` the field will resolve to `nil`, if it returns a truthy value, control will be passed to the resolve function. Of course, this can be used with `authorize!` as well. +### Scopes + +Pundit scopes are supported by using `scope` in the field definition + +```ruby +field :posts + scope + resolve ... +end +``` + +By default, this will use the Scope definied in the `PostPolicy`. If you do not want to define a scope inside of the policy, you can also pass a lambda to `scope`. The return value will be passed to `resolve` as first argument. + +```ruby +field :posts + scope ->(_root, _args, ctx) { Post.where(owner: ctx[:current_user]) } + resolve ->(posts, args, ctx) { ... } +end +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/lib/graphql-pundit.rb b/lib/graphql-pundit.rb index 1fdbfe0..7bf5d73 100644 --- a/lib/graphql-pundit.rb +++ b/lib/graphql-pundit.rb @@ -19,6 +19,15 @@ def self.assign_authorize(raise_unauthorized) call(defn, opts) end end - Field.accepts_definitions authorize: assign_authorize(false) - Field.accepts_definitions authorize!: assign_authorize(true) + + def self.assign_scope + lambda do |defn, proc = :infer_scope| + Define::InstanceDefinable::AssignMetadataKey.new(:scope). + call(defn, proc) + end + end + + Field.accepts_definitions(authorize: assign_authorize(false), + authorize!: assign_authorize(true), + scope: assign_scope) end diff --git a/lib/graphql-pundit/instrumenter.rb b/lib/graphql-pundit/instrumenter.rb index 6676e77..8694bac 100644 --- a/lib/graphql-pundit/instrumenter.rb +++ b/lib/graphql-pundit/instrumenter.rb @@ -1,42 +1,27 @@ # frozen_string_literal: true require 'pundit' +require 'graphql-pundit/instrumenters/authorization' +require 'graphql-pundit/instrumenters/scope' module GraphQL module Pundit - # The authorization Instrumenter + # Intrumenter combining the authorization and scope instrumenters class Instrumenter - attr_reader :current_user + attr_reader :current_user, + :authorization_instrumenter, + :scope_instrumenter def initialize(current_user = :current_user) @current_user = current_user + @authorization_instrumenter = Instrumenters::Authorization. + new(current_user) + @scope_instrumenter = Instrumenters::Scope.new(current_user) end - def instrument(_type, field) - return field unless field.metadata[:authorize] - - old_resolve = field.resolve_proc - resolve_proc = resolve_proc(current_user, - old_resolve, - field.metadata[:authorize]) - field.redefine do - resolve resolve_proc - end - end - - private - - def resolve_proc(current_user, old_resolve, options) - lambda do |obj, args, ctx| - begin - result = authorize(current_user, obj, args, ctx, options) - raise ::Pundit::NotAuthorizedError unless result - old_resolve.call(obj, args, ctx) - rescue ::Pundit::NotAuthorizedError - error_message = "You're not authorized to do this" - raise GraphQL::ExecutionError, error_message if options[:raise] - end - end + def instrument(type, field) + scoped_field = scope_instrumenter.instrument(type, field) + authorization_instrumenter.instrument(type, scoped_field) end def authorize(current_user, obj, args, ctx, options) diff --git a/lib/graphql-pundit/instrumenters/authorization.rb b/lib/graphql-pundit/instrumenters/authorization.rb new file mode 100644 index 0000000..5d46c2a --- /dev/null +++ b/lib/graphql-pundit/instrumenters/authorization.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'pundit' + +module GraphQL + module Pundit + module Instrumenters + # Instrumenter that supplies `authorize` + class Authorization + attr_reader :current_user + + def initialize(current_user = :current_user) + @current_user = current_user + end + + def instrument(_type, field) + return field unless field.metadata[:authorize] + old_resolve = field.resolve_proc + resolve_proc = resolve_proc(current_user, + old_resolve, + field.metadata[:authorize]) + field.redefine do + resolve resolve_proc + end + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def resolve_proc(current_user, old_resolve, options) + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + lambda do |obj, args, ctx| + begin + result = if options[:proc] + options[:proc].call(obj, args, ctx) + else + query = options[:query].to_s + '?' + record = options[:record] || obj + ::Pundit.authorize(ctx[current_user], record, query) + end + raise ::Pundit::NotAuthorizedError unless result + old_resolve.call(obj, args, ctx) + rescue ::Pundit::NotAuthorizedError + if options[:raise] + raise GraphQL::ExecutionError, + "You're not authorized to do this" + end + end + end + end + end + end + end +end diff --git a/lib/graphql-pundit/instrumenters/scope.rb b/lib/graphql-pundit/instrumenters/scope.rb new file mode 100644 index 0000000..bcaa627 --- /dev/null +++ b/lib/graphql-pundit/instrumenters/scope.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'pundit' + +module GraphQL + module Pundit + module Instrumenters + # Instrumenter that supplies `scope` + class Scope + attr_reader :current_user + + def initialize(current_user = :current_user) + @current_user = current_user + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def instrument(_type, field) + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + scope = field.metadata[:scope] + return field unless scope + unless valid_value?(scope) + raise ArgumentError, 'Invalid value passed to `scope`' + end + + old_resolve = field.resolve_proc + + scope_proc = lambda do |obj, _args, ctx| + ::Pundit.policy_scope!(ctx[current_user], obj) + end + scope_proc = scope if proc?(scope) + + field.redefine do + resolve(lambda do |obj, args, ctx| + new_scope = scope_proc.call(obj, args, ctx) + old_resolve.call(new_scope, args, ctx) + end) + end + end + + private + + def valid_value?(value) + inferred?(value) || proc?(value) + end + + def proc?(value) + value.respond_to?(:call) + end + + def inferred?(value) + value == :infer_scope + end + end + end + end +end diff --git a/spec/graphql-pundit/instrumenter_spec.rb b/spec/graphql-pundit/instrumenters/authorization_spec.rb similarity index 97% rename from spec/graphql-pundit/instrumenter_spec.rb rename to spec/graphql-pundit/instrumenters/authorization_spec.rb index ef6e8da..56925cc 100644 --- a/spec/graphql-pundit/instrumenter_spec.rb +++ b/spec/graphql-pundit/instrumenters/authorization_spec.rb @@ -116,7 +116,7 @@ def test? end end -RSpec.describe GraphQL::Pundit::Instrumenter do +RSpec.describe GraphQL::Pundit::Instrumenters::Authorization do let(:instrumenter) { GraphQL::Pundit::Instrumenter.new } let(:instrumented_field) { instrumenter.instrument(nil, field) } let(:fail_test) { Test.new(:fail) } diff --git a/spec/graphql-pundit/instrumenters/scope_spec.rb b/spec/graphql-pundit/instrumenters/scope_spec.rb new file mode 100644 index 0000000..8b9d73a --- /dev/null +++ b/spec/graphql-pundit/instrumenters/scope_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class ScopeTest + def initialize(value) + @value = value + end + + def where(&block) + ScopeTest.new(@value.select(&block)) + end + + def to_a + @value + end +end + +class ScopeTestPolicy + class Scope + attr_reader :scope + + def initialize(_, scope) + @scope = scope + end + + def resolve + scope.where { |e| e.to_i < 20 } + end + end + + def initialize(_, _); end + + def test? + nil + end +end + +RSpec.describe GraphQL::Pundit::Instrumenters::Scope do + let(:instrumenter) { GraphQL::Pundit::Instrumenter.new } + let(:instrumented_field) { instrumenter.instrument(nil, field) } + let(:result) { instrumented_field.resolve(subject, {}, {}) } + + subject { ScopeTest.new([1, 2, 3, 22, 48]) } + + context 'without authorization' do + context 'inferred scope' do + let(:field) do + GraphQL::Field.define(type: 'String') do + name :notTest + scope + resolve ->(obj, _args, _ctx) { obj.to_a } + end + end + + it 'filters the list' do + expect(result).to match_array([1, 2, 3]) + end + end + + context 'explicit scope' do + let(:field) do + GraphQL::Field.define(type: 'String') do + name :notTest + scope ->(scope, _args, _ctx) { scope.where { |e| e > 20 } } + resolve ->(obj, _args, _ctx) { obj.to_a } + end + end + + it 'filters the list' do + expect(result).to match_array([22, 48]) + end + end + end + + context 'with authorization' do + context 'inferred scope' do + let(:field) do + GraphQL::Field.define(type: 'String') do + name :test + authorize + scope + resolve ->(obj, _args, _ctx) { obj.to_a } + end + end + + it 'returns nil' do + expect(result).to eq(nil) + end + end + + context 'explicit scope' do + let(:field) do + GraphQL::Field.define(type: 'String') do + name :test + authorize + scope ->(scope, _args, _ctx) { scope.where { |e| e > 20 } } + resolve ->(obj, _args, _ctx) { obj.to_a } + end + end + + it 'returns nil' do + expect(result).to eq(nil) + end + end + end + + context 'invalid scope argument' do + let(:field) do + GraphQL::Field.define(type: 'String') do + name :test + authorize + scope 'invalid value' + resolve ->(obj, _args, _ctx) { obj.to_a } + end + end + + it 'raises an error' do + expect { result }.to raise_error(ArgumentError) + end + end +end