Skip to content

Latest commit

 

History

History
325 lines (237 loc) · 10.5 KB

graphql.md

File metadata and controls

325 lines (237 loc) · 10.5 KB

GraphQL integration

You can use Action Policy as an authorization library for your GraphQL Ruby application via the action_policy-graphql gem.

This integration provides the following features:

Getting Started

First, add the action_policy-graphql gem to your Gemfile (see installation instructions).

Then, include ActionPolicy::GraphQL::Behaviour to your base type (or any other type/mutation where you want to use authorization features):

# For fields authorization, lists scoping and rules exposing
class Types::BaseObject < GraphQL::Schema::Object
  include ActionPolicy::GraphQL::Behaviour
end

# For using authorization helpers in mutations
class Types::BaseMutation < GraphQL::Schema::Mutation
  include ActionPolicy::GraphQL::Behaviour
end

# For using authorization helpers in resolvers
class Types::BaseResolver < GraphQL::Schema::Resolver
  include ActionPolicy::GraphQL::Behaviour
end

Authorization Context

By default, Action Policy uses context[:current_user] as the user authorization context.

NOTE: see below for more information on what's included into ActionPolicy::GraphQL::Behaviour.

Authorizing Fields

You can add authorize: true option to any field (=underlying object) to protect the access (it's equal to calling authorize! object, to: :show?):

# authorization could be useful for find-like methods,
# where the object is resolved from the provided params (e.g., ID)
field :home, Home, null: false, authorize: true do
  argument :id, ID, required: true
end

def home(id:)
  Home.find(id)
end

# Without `authorize: true` the code would look like this
def home(id:)
  Home.find(id).tap { |home| authorize! home, to: :show? }
end

You can use authorization options to customize the behaviour, e.g. authorize: {to: :preview?, with: CustomPolicy}.

By default, if a user is not authorized to access the field, an ActionPolicy::Unauthorized exception is raised.

If you want to return a nil instead, you should add raise: false to the options:

# NOTE: don't forget to mark your field as nullable
field :home, Home, null: true, authorize: {raise: false}

You can make non-raising behaviour a default by setting a configuration option:

ActionPolicy::GraphQL.authorize_raise_exception = false

You can also change the default show? rule globally:

ActionPolicy::GraphQL.default_authorize_rule = :show_graphql_field?

If you want to perform authorization before resolving the field value, you can use preauthorize: * option:

field :homes, [Home], null: false, preauthorize: {with: HomePolicy}

def homes
  Home.all
end

The code above is equal to:

field :homes, [Home], null: false

def homes
  authorize! "homes", to: :index?, with: HomePolicy
  Home.all
end

You can specify the default raising behaviour for preauthorize: by setting a configuration option:

# By default, it fallbacks to .authorize_raise_exception
ActionPolicy::GraphQL.preauthorize_raise_exception = false

NOTE: we pass the field's name as the record to the policy rule. We assume that pre-authorization rules do not depend on the record itself and pass the field's name for debugging purposes only.

You can customize the authorization options, e.g. authorize: {to: :preview?, with: CustomPolicy}.

NOTE: unlike authorize: * you MUST specify the with: SomePolicy option. The default authorization rule depends on the type of the field:

  • for lists we use index? (configured by ActionPolicy::GraphQL.default_preauthorize_list_rule parameter)
  • for singleton fields we use show? (configured by ActionPolicy::GraphQL.default_preauthorize_node_rule parameter)

Class-level authorization

You can use Action Policy in the class-level authorization hooks (self.authorized?) like this:

class Types::Friendship < Types::BaseObject
  def self.authorized?(object, context)
    super &&
      allowed_to?(
        :show?,
        object,
        # NOTE: you must provide context explicitly
        context: {user: context[:current_user]}
      )
  end
end

Authorizing Mutations

A mutation is just a Ruby class with a single API method. There is nothing specific in authorizing mutations: from the Action Policy point of view, they are just behaviours.

If you want to authorize the mutation, you call authorize! method. For example:

class Mutations::DestroyUser < Types::BaseMutation
  argument :id, ID, required: true

  def resolve(id:)
    user = User.find(id)

    # Raise an exception if the user has not enough permissions
    authorize! user, to: :destroy?
    # Or check without raising and do what you want
    #
    #     if allowed_to?(:destroy?, user)

    user.destroy!

    {deleted_id: user.id}
  end
end

Check out this issue on how you can implement a verify_authorized callback for your mutations: #28.

Using preauthorize: * with mutations

Since mutation is also a GraphQL field, we can also use our custom authorize: * and preauthorize: * options. However, using authorize: * for mutations is deprecated and will raise an error in the future versions: it doesn't make any sense because it's called after the field has been resolved (i.e., mutation has been executed).

It is possible to override the default raising behaviour for mutation only via the following configuration option:

# By default, it fallbacks to .preauthorize_raise_exception
ActionPolicy::GraphQL.preauthorize_mutation_raise_exception = true

Handling exceptions

The query would fail with ActionPolicy::Unauthorized exception when using authorize: true (in raising mode) or calling authorize! explicitly.

That could be useful to handle this exception and send a more detailed error message to the client, for example:

Please make sure you have added error handling to your schema with use GraphQL::Execution::Errors.

# in your schema file
rescue_from(ActionPolicy::Unauthorized) do |exp|
  raise GraphQL::ExecutionError.new(
    # use result.message (backed by i18n) as an error message
    exp.result.message,
    # use GraphQL error extensions to provide more context
    extensions: {
      code: :unauthorized,
      fullMessages: exp.result.reasons.full_messages,
      details: exp.result.reasons.details
    }
  )
end

Scoping Data

You can add authorized_scope: true option to a field (list or connection) to apply the corresponding policy rules to the data:

class CityType < ::Common::Graphql::Type
  # It would automatically apply the relation scope from the EventPolicy to
  # the relation (city.events)
  field :events, EventType.connection_type,
    null: false,
    authorized_scope: true

  # you can specify the policy explicitly
  field :events, EventType.connection_type,
    null: false,
    authorized_scope: {with: CustomEventPolicy}

  # without the option you would write the following code
  def events
    authorized_scope object.events
    # or if `with` option specified
    authorized_scope object.events, with: CustomEventPolicy
  end
end

NOTE: you cannot use authorize: * and authorized_scope: * at the same time but you can combine preauthorize: * with authorized_scope: *.

See the documentation on scoping.

Exposing Authorization Rules

With action_policy-graphql gem, you can easily expose your authorization logic to the client in a standardized way.

For example, if you want to "tell" the client which actions could be performed against the object you can use the expose_authorization_rules macro to add authorization-related fields to your type:

class ProfileType < Types::BaseType
  # Adds can_edit, can_destroy fields with
  # AuthorizationResult type.

  # NOTE: prefix "can_" is used by default, no need to specify it explicitly
  expose_authorization_rules :edit?, :destroy?, prefix: "can_"
end

NOTE: you can use aliases here as well as defined rules.

NOTE: This feature relies the failure reasons and the i18n integration extensions. If your policies don't include any of these, you won't be able to use it.

Then the client could perform the following query:

{
  post(id: $id) {
    canEdit {
      # (bool) true|false; not null
      value
      # top-level decline message ("Not authorized" by default); null if value is true
      message
      # detailed information about the decline reasons; null if value is true or you don't have "failure reasons" extension enabled
      reasons {
        details # JSON-encoded hash of the form { "event" => [:privacy_off?] }
        fullMessages # Array of human-readable reasons
      }
    }

    canDestroy {
      # ...
    }
  }
}

You can override a custom authorization field prefix (can_):

ActionPolicy::GraphQL.default_authorization_field_prefix = "allowed_to_"

You can specify a custom field name as well (only for a single rule):

class ProfileType < ::Common::Graphql::Type
  # Adds can_create_post field.

  expose_authorization_rules :create?, with: PostPolicy, field_name: "can_create_post"
end

Custom Behaviour

Including the default ActionPolicy::GraphQL::Behaviour is equal to adding the following to your base class:

class Types::BaseObject < GraphQL::Schema::Object
  # include Action Policy behaviour and its extensions
  include ActionPolicy::Behaviour
  include ActionPolicy::Behaviours::ThreadMemoized
  include ActionPolicy::Behaviours::Memoized
  include ActionPolicy::Behaviours::Namespaced

  # define authorization context
  authorize :user, through: :current_user

  # add a method helper to get the current_user from the context
  def current_user
    context[:current_user]
  end

  # extend the field class to add `authorize` and `authorized_scope` options
  field_class.prepend(ActionPolicy::GraphQL::AuthorizedField)

  # add `expose_authorization_rules` macro
  include ActionPolicy::GraphQL::Fields
end

Feel free to create your own behaviour by adding only the functionality you need.