SkillAgentSearch skills...

Pundit

Authorization for Lucky Crystal Apps

Install / Use

/learn @stephendolan/Pundit
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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

Related Skills

View on GitHub
GitHub Stars19
CategoryDevelopment
Updated3mo ago
Forks2

Languages

Crystal

Security Score

92/100

Audited on Jan 7, 2026

No findings