Skip to content

stephendolan/pundit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

82 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

This shard is very much still a work in progress. I'm using it in my own production apps, but the API is subject to major breaking changes and reworks until I tag v1.0.

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. (Optional) Capture Pundit exceptions in src/actions/errors/show.cr with a new #render override:

    # Capture Pundit authorization exceptions to handle it elegantly
    def render(error : Pundit::NotAuthorizedError)
      if html?
        error_html "Sorry, you're not authorized to access that", status: 401
      else
        error_json "Not authorized", status: 401
      end
    end
  8. 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 might look like this:

class BookPolicy < ApplicationPolicy(Book)
  def index?
    # If you want to either allow or deny all visitors, simply return `true` or `false`
    true
  end

  def show?
    # You can reference other methods if you want to share authorization between them
    update?
  end

  def create?
    # Only signed-in users can create books
    return false unless signed_in_user = user
  end

  def update?
    # Only the owner of a book can update it
    return false unless requested_book = record
    
    requested_book.owner == user
  end

  def delete?
    # You can reference other methods if you want to share authorization between them
    update?
  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):

  • Run lucky pundit.init --user-model {Account}, or modify your ApplicationPolicy's initialize content like this:

    abstract class ApplicationPolicy(T)
      getter account
      getter record
    
      def initialize(@account : Account?, @record : T? = nil)
      end
    end
  • Update the include of the Pundit::ActionHelpers module in BrowserAction:

    # src/actions/browser_action.cr
    include Pundit::ActionHelpers(Account)

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

  • The Pundit Ruby gem was what formed my need as a programmer for this kind of simple approach to authorization
  • The Praetorian Crystal shard took an excellent first step towards proving out the Pundit model in Crystal