Authorization
Overview
Section titled “Overview”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.
Pundit-Style Policies
Section titled “Pundit-Style Policies”A popular approach is to define policy objects that encapsulate authorization rules. This keeps logic out of resolvers and makes it testable in isolation.
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 endendThen use the policy in a custom query or mutation:
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 endendAvoiding N+1 Queries in Policy Checks
Section titled “Avoiding N+1 Queries in Policy Checks”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 querydef comments object.comments.select do |comment| CommentPolicy.new(context[:current_user], comment).show? endendSolution 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? endendSolution 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:
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 endendThen use it in your resolver:
def comments object.comments.select do |comment| dataloader.with(PolicySource, context[:current_user], CommentPolicy, :show?).load(comment) endendSolution 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.
Scoping Collections
Section titled “Scoping Collections”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 :listendclass 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 endendGuarding Mutations
Section titled “Guarding Mutations”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, DeleteArticleendclass 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 endendField-Level Visibility
Section titled “Field-Level Visibility”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:
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 endendApply it to sensitive fields in a custom type that inherits from Quail::Object:
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: trueendQuail::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.
Role-Based Access with Context
Section titled “Role-Based Access with Context”For simpler setups, check roles directly in resolvers using the context:
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 endendRaising Authorization Errors
Section titled “Raising Authorization Errors”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
articleendThis places the error message in the response errors array while returning null for the field data.
Summary
Section titled “Summary”| Technique | Best for |
|---|---|
| Policy objects | Complex, testable business rules |
| Scoped collections | Filtering list queries by permission |
| Mutation overrides | Write-path authorization |
authorized? on fields | Hiding sensitive fields from unauthorized users |
| Role checks in context | Simple role gates |
GraphQL::ExecutionError | Explicit 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.