Skip to content

Authorization

Quail handles authentication by passing current_user through the GraphQL context (see the Authentication guide). Authorization deciding what a user can access is your responsibility. This guide covers common patterns for field-level and operation-level access control.

A popular approach is to define policy objects that encapsulate authorization rules. This keeps logic out of resolvers and makes it testable in isolation.

app/policies/article_policy.rb
class ArticlePolicy
attr_reader :user, :article
def initialize(user, article)
@user = user
@article = article
end
def show?
article.published? || article.author == user
end
def update?
article.author == user
end
def destroy?
user.admin? || article.author == user
end
end

Then use the policy in a custom query or mutation:

app/graphql/queries/article.rb
class ArticleQuery < Quail::Query
type ArticleResource.graphql_type, null: true
argument :id, ID, required: true
def resolve(id:)
article = Article.find_by(id: id)
return nil unless article
policy = ArticlePolicy.new(context[:current_user], article)
policy.show? ? article : nil
end
end

Per-object policy checks in nested GraphQL resolvers are a common source of N+1 queries. Consider a query that loads 25 articles, each with comments; if CommentPolicy#show? hits the database (e.g., checking comment.post.author), you get 25+ extra queries that the dataloader cannot batch because the policy call is imperative.

The problem:

# This causes N+1 — each comment triggers a separate policy query
def comments
object.comments.select do |comment|
CommentPolicy.new(context[:current_user], comment).show?
end
end

Solution 1: Preload policy dependencies

If your policy inspects associations, preload them so the policy hits already-loaded data instead of the database:

def comments
object.comments.includes(post: :author).select do |comment|
CommentPolicy.new(context[:current_user], comment).show?
end
end

Solution 2: Batch policy checks with a Dataloader source

For more complex cases, write a custom GraphQL::Dataloader::Source that collects all records, preloads their policy dependencies in one pass, then evaluates each policy:

app/graphql/sources/policy_source.rb
class PolicySource < GraphQL::Dataloader::Source
def initialize(user, policy_class, action)
@user = user
@policy_class = policy_class
@action = action
end
def fetch(records)
# Preload whatever the policy needs in ONE query
ActiveRecord::Associations::Preloader.new(
records: records,
associations: [:post, :author]
).call
records.map do |record|
@policy_class.new(@user, record).public_send(@action)
end
end
end

Then use it in your resolver:

def comments
object.comments.select do |comment|
dataloader.with(PolicySource, context[:current_user], CommentPolicy, :show?).load(comment)
end
end

Solution 3: Scope instead of filter

The most efficient approach is to avoid per-object checks entirely by scoping the query (see Scoping Collections below). If a record appears in the scoped result set, it is authorized — no per-object policy needed.

For list queries, scope the results to only what the user is allowed to see. Override the default list query with a scoped version:

class ArticleResource
include Quail::Resource
attributes :id, :title, :body, :published_at
writable_attributes :title, :body
skip_queries :list
end
app/graphql/queries/articles.rb
class Articles < Quail::Query
type ArticleResource.graphql_type.connection_type, null: false
def resolve
user = context[:current_user]
if user&.admin?
Article.all
elsif user
Article.where(published: true).or(Article.where(author: user))
else
Article.where(published: true)
end
end
end

Override mutations to add authorization checks before performing writes:

class ArticleResource
include Quail::Resource
attributes :id, :title, :body
writable_attributes :title, :body
override_mutation :update, UpdateArticle
override_mutation :delete, DeleteArticle
end
app/graphql/mutations/update_article.rb
class UpdateArticle < Quail::Mutation
argument :id, ID, required: true
argument :title, String, required: false
argument :body, String, required: false
field :article, ArticleResource.graphql_type, null: true
field :errors, [String], null: false
def resolve(id:, **attrs)
article = Article.find_by(id: id)
return { article: nil, errors: ["Not found"] } unless article
unless ArticlePolicy.new(context[:current_user], article).update?
return { article: nil, errors: ["Not authorized"] }
end
if article.update(attrs.compact)
{ article: article, errors: [] }
else
{ article: nil, errors: article.errors.full_messages }
end
end
end

Use graphql-ruby’s built-in visible? and authorized? methods to hide or protect individual fields. Define a custom field class and wire it into your type:

app/graphql/fields/authorized_field.rb
class AuthorizedField < Quail::Schema::Field
argument_class Quail::Schema::Argument
def initialize(*args, admin_only: false, **kwargs, &block)
@admin_only = admin_only
super(*args, **kwargs, &block)
end
def authorized?(obj, args, ctx)
return true unless @admin_only
ctx[:current_user]&.admin? || false
end
end

Apply it to sensitive fields in a custom type that inherits from Quail::Object:

app/graphql/types/user_type.rb
class UserType < Quail::Object
field_class AuthorizedField
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false, admin_only: true
field :role, String, null: false, admin_only: true
end

Quail::Object is Quail’s wrapper around GraphQL::Schema::Object and Quail::Schema::Field resolves to GraphQL::Schema::Field, so all standard graphql-ruby features like field_class and authorized? work as expected. When authorized? returns false, graphql-ruby returns nil for that field rather than raising an error.

For simpler setups, check roles directly in resolvers using the context:

app/graphql/mutations/promote_user.rb
class PromoteUser < Quail::Mutation
argument :user_id, ID, required: true
argument :role, String, required: true
field :user, UserResource.graphql_type, null: true
field :errors, [String], null: false
def resolve(user_id:, role:)
unless context[:current_user]&.admin?
return { user: nil, errors: ["Admin access required"] }
end
user = User.find_by(id: user_id)
return { user: nil, errors: ["User not found"] } unless user
if user.update(role: role)
{ user: user, errors: [] }
else
{ user: nil, errors: user.errors.full_messages }
end
end
end

Instead of returning nil or error arrays, you can raise a GraphQL::ExecutionError to surface authorization failures in the standard GraphQL errors response:

def resolve(id:)
article = Article.find_by(id: id)
raise GraphQL::ExecutionError, "Not found" unless article
unless ArticlePolicy.new(context[:current_user], article).show?
raise GraphQL::ExecutionError, "Not authorized"
end
article
end

This places the error message in the response errors array while returning null for the field data.

TechniqueBest for
Policy objectsComplex, testable business rules
Scoped collectionsFiltering list queries by permission
Mutation overridesWrite-path authorization
authorized? on fieldsHiding sensitive fields from unauthorized users
Role checks in contextSimple role gates
GraphQL::ExecutionErrorExplicit error messages in the response

Quail stays out of your authorization layer by design. Pick the pattern that fits your app’s complexity and combine techniques as needed.