palkan/action_policy-graphql

Using authorized_scope on input

lifeiscontent opened this issue · 7 comments

@palkan is it possible to use authorized_Scope on an input like you would with params from rail's strong parameters?

I saw that you had this feature in Rails and it started to make me wonder why inputs don't work like strong parameters in the context of GraphQL Ruby.

Yeah, I think it should be possible.

Let's imaging what it could be like using an example from graphql ruby docs.

class Types::PostAttributes < GraphQL::Schema::InputObject
  description "Attributes for creating or updating a blog post"
  argument :title, String, "Header for the post", required: true
  argument :full_text, String, "Full body of the post", required: true
  argument :categories, [Types::PostCategory], required: false
end

class Mutations::CreatePost < GraphQL::Schema::RelayClassicMutation
  input_object_class Types::PostAttributes

  def resolve(input:)
    authorized_input = authorized_scope(input, with: PostPolicy)

    # ...
  end
end

class ApplicationPolicy < ActionPolicy::Base
  # define scope type matcher based on the data type
  scope_matcher :graphq_input, GraphQL::Schema::InputObject
end

class PostPolicy < ApplicationPolicy
  graphql_input_scope do |input|
    # do something with input here
  end
end

Not sure how exactly we need to filter GraphQL inputs. I think, raising an error if some unauthorized field was set.

@palkan yeah, I think you'd raise an ActionPolicy::Unauthorized do you know what that might look like here? e.g. if an input has a user_id and its not the id of the viewer

if an input has a user_id and its not the id of the viewer

Then, why do you need a user_id at all ?)

A straightforward way to achieve this is:

graphql_input_scope do |input|
  raise ActionPolicy::Unauthorized if input.user_id != user.id
  # return input if all is OK
  input
end

@palkan because the viewer could be an admin creating on behalf of the user. and if a user (hacker) tries to pass a different user ID, it should throw an error. do you have a better way of handling this kind of scenario?

I see two ways of dealing with this.

  1. Using scopes to filter the input object. In this case, we delete the user_id from the input (that's similar to params.permit in Rails controllers):
graphql_input_scope do |input|
   input.argument_values.delete(:user_id) unless user.admin?
   input
end

Not sure about the code above, graphql-ruby looks a bit complicated here. I took a look a these files:

  1. Using visibility filters and hide the argument from non-admins altogether.

That would likely require adding a custom argument class and a filter:

class ArgumentWithVisibility < GraphQL::Schema::Argument
  attr_reader :admin_only
  alias admin_only? admin_only

  def initialize(name, type, desc = nil, admin_only: false, **kwargs)
    @admin_only = admin_only
    super(name, type, desc, **kwargs)
  end
end

class BaseInputObject  < GraphQL::Schema::InputObject
  argument_class ArgumentWithVisibility
end

filter = ->(member, ctx) {
  next true unless member.is_a?(ArgumentWithVisibility)
  !member.admin_only? || ctx[:current_user]&.admin?
}
MySchema.execute(query_string, except: filter)

class Types::PostAttributes < BaseInputObject
  # ...

  # Finally, in your schema
  argument :user_id, ID, required: false, admin_only: true
end
Envek commented

@palkan, sorry for commenting on stale issue, but can you please explain how to properly use raising of ActionPolicy::Unauthorized from scopes, especially scopes for Strong Params.

The problem is that exception initializer requires two params to be passed: policy and rule. See:
https://github.com/palkan/action_policy/blob/e5337c01d1caa652828ecd804406bea821c46975/lib/action_policy/authorizer.rb#L24

And while scopes are always defined in a policy, there is no problem to pass, but some existing rule may not fit scope's logic.

And most importantly, it is not clear how to pass rejection reasons.

but some existing rule may not fit scope's logic

You can use any Symbol, it's just a name of the rule (or scope) that has triggered the exception.

And most importantly, it is not clear how to pass rejection reasons.

Hm, we don't have result object initialized while applying scopes. We can do smth like that:

params_filter do |params|
  if user.admin?
    params.permit(:name, :email, :role)
  else
    reject!(:invalid_params) if params.key?(:role) || params.key?(:email)

    params.permit(:name)
  end
end

def reject!(reason = nil)
  # populate result object
  apply(:unauthorized_params)
  result.reasons.add(self, reason) if reason
  raise Unauthorized.new(self, result.rule)
end

# Always return false — a hack to reject scopes
def unauthorized_params
  false
end

Looks hacky(

We can make such #reject! (without apply hacks) a part of the API.