Lighthouse GraphQL RailsLighthouse GraphQL Rails
Docs
Directives
Example app
Changelog
RubyGems
GitHub
Docs
Directives
Example app
Changelog
RubyGems
GitHub
  • Getting Started

    • Motivation
    • Getting Started
    • Configuration
  • The Basics

    • Resolvers, Mutations and Builders
    • Generators
    • Unions, Interfaces and Scalars
    • Relay: Global Object Identification
    • Relationships
    • Filtering & Ordering
    • Mutations
    • File Uploads
    • Validation
    • Subscriptions
  • Security

    • Authentication & Authorization
    • Query Complexity & Depth
  • Performance

    • Performance at scale
    • Caching
  • Digging Deeper

    • Schema Organisation
    • Custom Directives
    • Events
    • Apollo Federation
    • Best Practices
  • API Reference

    • Directives Reference

Authentication & Authorization

Enforce authentication and authorization declaratively in your schema, instead of in resolvers whose only job is a policy check. @guard requires a user. The @can* family checks a policy ability, Pundit by default. This is PHP Lighthouse's v6 design, which replaced its single @can with one directive per thing being authorized.

Configuration

The defaults work with a Rails app that puts current_user (or pundit_user) in the GraphQL context and uses Pundit, so typically no configuration is needed:

Lighthouse.configure do |config|
  # The authenticated user (nil if none). Default:
  config.authenticated_user = ->(ctx) { ctx[:current_user] }

  # The "user" handed to policies. Default: ctx[:pundit_user], else the authenticated user.
  config.policy_user = ->(ctx) { ctx[:pundit_user] }

  # How an ability is checked. Default (Pundit):
  config.authorizer = ->(user, record, ability, args = []) do
    Pundit.policy!(user, record).public_send("#{ability}?", *args)
  end
end

Point authorizer at any system (CanCanCan, ActionPolicy, a lambda) to use something else. It receives args only when a directive passes some, so a three-argument authorizer keeps working for checks without them.

@guard — require authentication

Raises Unauthenticated. when there is no user. It runs before everything else on the field.

type Query {
  me: User @guard
}

The @can* family

Each directive checks ability with the policy, e.g. PostPolicy#update? for ability: "update". They differ in what they authorize:

DirectiveAuthorizesTypical use
@canModelthe model classcreate? before @create
@canFindthe models whose keys an argument holdsupdate? before @update
@canQuerythe models the field's argument filters selectbulk reads or writes
@canResolvedwhat the field resolved to (each item of a list)read-only fields
@canRootthe parent objecta sensitive field on a type
type Query {
  post(id: ID! @eq): Post @find @canResolved(ability: "show")
  draftCount(authorId: ID @eq): Int @count(model: "Post") @canQuery(ability: "show")
}

type Mutation {
  createPost(input: CreatePostInput! @spread): Post @create @canModel(ability: "create")
  updatePost(input: UpdatePostInput! @spread): Post @update @canFind(ability: "update", find: "input.id")
}

type User {
  email: String @canRoot(ability: "view_email")
}

With Pundit, @canFind(ability: "update", find: "input.id") loads the post and calls PostPolicy.new(pundit_user, post).update?. @canModel(ability: "create") calls PostPolicy.new(pundit_user, Post).create?.

All of them run before the field resolves, except @canResolved. That one needs the result, so use it only on fields that do not change data.

Arguments

Every @can* directive takes:

ArgumentMeaning
ability: String!The policy method, without ?
injectArgs: Boolean = falsePass the field's arguments (a Hash) to the policy method
args: CanArgsStatic values passed after them, e.g. args: 2 or args: ["a", "b"]
action: CanAction = EXCEPTION_PASSWhat a denial does (below)
returnValue: CanArgsThe value returned for action: RETURN_VALUE
class PostPolicy < ApplicationPolicy
  def publish?(args)      # @canFind(ability: "publish", find: "id", injectArgs: true)
    user.admin? && args[:title] != record.title
  end

  def edit?(level)        # @canFind(ability: "edit", find: "id", args: 2)
    user.level >= level
  end
end

Plus, per directive:

  • @canFind(find: String!, model: String, findOrFail: Boolean = true, scopes: [String!]). find is the argument holding the primary key or keys, and may be a dot path ("input.id"). A key that matches no record fails with Couldn't find Post with 'id'=…, unless findOrFail: false. scopes are model scopes applied to the lookup.
  • @canModel(model: String).
  • @canQuery(model: String, scopes: [String!]). scopes narrow the records being authorized, not the field's result.

The model defaults to the model: of another directive on the field (as in @count(model: "Post") @canQuery), then to the field's return type.

action: what a denial does

actionResult
EXCEPTION_PASS (default)The error reaches the client: This action is unauthorized., or @canFind's not-found message
EXCEPTION_NOT_AUTHORIZEDAlways This action is unauthorized., which hides whether a record exists
RETURN_VALUEThe field returns returnValue instead of resolving
post(id: ID!): Post @canFind(ability: "show", find: "id", action: EXCEPTION_NOT_AUTHORIZED)

action applies to the directive's own checks only. Errors from the resolver itself are untouched.

Ordering

The order you write directives in does not matter. They run as:

  1. @guard / @auth (authentication)
  2. @canFind, @canModel, @canQuery, @canRoot (and the check of @canResolved, after the resolver)
  3. @validator (see validation.md)
  4. @cache, then the resolver

So a cached value is still authorized on every request.

Multi-tenancy vs. authorization

Keep the two layers distinct:

  • Tenancy / scoping decides which account's records are visible. It belongs in a builder or query class, e.g. account.posts.
  • Authorization decides whether this user may do this to this record. It belongs in @can* and your policies.

For lists, scope with a builder (e.g. Pundit's policy_scope) rather than authorizing item by item.

Migrating from @can

@can is gone. Its modes map one to one:

BeforeAfter
@can(ability: "show")@canResolved(ability: "show")
@can(ability: "view_email", root: true)@canRoot(ability: "view_email")
@can(ability: "update", find: "id", model: "Post")@canFind(ability: "update", find: "id", model: "Post")

One difference: @canResolved authorizes each item of a list. @can skipped lists.

Edit this page on GitHub
Next
Query Complexity & Depth