Pundit

Shard CI API Documentation Website GitHub release

A simple Crystal shard for managing authorization in Lucky applications. Intended to mimic the excellent Ruby Pundit gem.

Lucky Installation

  1. Add the dependency to your shard.yml:

    # shard.yml
    dependencies:
      pundit:
        github: stephendolan/pundit
  2. Run shards install

  3. Require the shard in your Lucky application

    # shards.cr
    require "pundit"
  4. Require the tasks in your Lucky application

    # tasks.cr
    require "pundit/tasks/**"
  5. Require a new directory for policy definitions

    # app.cr
    require "./policies/**"
  6. Include the Pundit::ActionHelpers module in BrowserAction:

    # src/actions/browser_action.cr
    include Pundit::ActionHelpers(User)
  7. Run the initializer to create your ApplicationPolicy if you don't want the default:

    lucky pundit.init

Usage

Creating policies

The easiest way to create new policies is to use the built-in Lucky task! After following the steps in the Installation section, simply run lucky gen.policy Book, for example, to create a new BookPolicy in your application.

Your policies must inherit from the provided ApplicationPolicy(T) abstract class, where T is the model you are authorizing against.

For example, the BookPolicy we created with lucky gen.policy Book looks like this:

class BookPolicy < ApplicationPolicy(Book)
  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def update?
    false
  end

  def delete?
    false
  end
end

The following methods are provided in ApplicationPolicy:

| Method Name | Default Value | | ----------- | ------------- | | index? | false | | show? | false | | create? | false | | new? | create? | | update? | false | | edit? | update? | | delete? | false |

Authorizing actions

Let's say we have a Books::Index action that looks like this:

class Books::Index < BrowserAction
  get "/books/index" do
    html IndexPage, books: BookQuery.new
  end
end

To use Pundit for authorization, simply add an authorize call:

class Books::Index < BrowserAction
  get "/books/index" do
    authorize

    html IndexPage, books: BookQuery.new
  end
end

Behind the scenes, this is using the action's class name to check whether the BookPolicy's index? method is permitted for current_user. If the call fails, a Pundit::NotAuthorizedError is raised.

The authorize call above is identical to writing this:

BookPolicy.new(current_user).index? || raise Pundit::NotAuthorizedError.new

You can also leverage specific records in your authorization. For example, say we have a Books::Update action that looks like this:

post "/books/:book_id/update" do
  book = BookQuery.find(book_id)

  SaveBook.update(book, params) do |operation, book|
    redirect Home::Index
  end
end

We can add an authorize call to check whether or not the user is permitted to update this specific book like this:

post "/books/:book_id/update" do
  book = BookQuery.find(book_id)

  authorize(book)

  SaveBook.update(book, params) do |operation, book|
    redirect Home::Index
  end
end

Authorizing views

Say we have a button to create a new book:

def render
  button "Create new book"
end

To ensure that the current_user is permitted to create a new book before showing the button, we can wrap the button in a policy check:

def render
  if BookPolicy.new(current_user).create?
    button "Create new book"
  end
end

Overriding the User model

If your application doesn't return an instance of User from your current_user method, you'll need to make the following updates (we're using Account as an example):

Handling authorization errors

If a call to authorize fails, a Pundit::NotAuthorizedError will be raised.

You can handle this elegantly by adding an overloaded render method to your src/actions/errors/show.cr action:

# This class handles error responses and reporting.
#
# https://luckyframework.org/guides/http-and-routing/error-handling
class Errors::Show < Lucky::ErrorAction
  DEFAULT_MESSAGE = "Something went wrong."
  default_format :html

  # Capture Pundit authorization exceptions to handle it elegantly
  def render(error : Pundit::NotAuthorizedError)
    if html?
      # We might want to throw an appropriate status and message
      error_html "Sorry, you're not authorized to access that", status: 401

      # Or maybe we just redirect users back to the previous page
      # redirect_back fallback: Home::Index
    else
      error_json "Not authorized", status: 401
    end
  end
end

Contributing

  1. Fork it (https://github.com/stephendolan/pundit/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

Inspiration