As your users create and manage data, you need to decide what is visible and editable by any user in the application. You set out by scoping your domain resources to the user or to the user's team and are off to the races!
Your service grows (hooray!) and users now want separate teams inside their organization. They want certain users to have admin privileges to handle billing and team management. Users accidentally delete things and want them back so you add the ability to undo. What data is accessible to users quickly becomes unaddressable in an ad-hoc fashion. This is where Pundit comes in.
Pundit is an authorization library for managing access to resources in an application. Pundit offers two abstractions--Policies and Scopes. Policies confirm a user has access to a specific resource. Scopes filter collections for a given user. Most commonly Pundit is found in the controller and view layer but can also be used in service objects. It effectively leverages convention over configuration, dependency injection, and inheritance to make authorization robust and maintainable.
You can understand Pundit's usage in 5 minutes of reading the readme and can understand the source code in 10 - 15 minutes more. Whether your authorization needs are simple or complex, Pundit is an excellent solution for localizing authorization logic and ensuring data authorization is considered for every endpoint in your application.
Using convention over configuration, Policies are easy to implement and understand. Each
Policy's name begins with the name of the model it protects and is suffixed with
Policy implements query methods that map to controller's actions. The naming convention is then used by the
PolicyFinder to infer which policy to lookup when
authorize is called.
class ArticlePolicy < ApplicationPolicy attr_reader :user, :article def initialize(user, article) @user = user @article = article end def show? user.payed_for?(article) && !article.deleted? end end
Policy api could be improved by returning the resource when
authorize is successful. Doing so would avoid the need to conditionally check the success of
authorize in the controller. When this hypothetical
authorize api is paired with the responders gem, authorization protection is introducible without any concessions of clarity.
class Api::ArticlesController < Api::BaseController def show respond_with :api, article, serializer: ArticleSerializer end private def article @article ||= authorize Article.find(params[:id]) end end
Developers generally append scopes in the controller or add default scopes to filter collections to a specific user. Default scopes can become unwieldy and sometimes dangerous. Calling unscoped on a relation removes all scopes which is rarely the intent. Manually appending scopes in the controller can work for small applications but it is too easy to accidentally forget one.
Pundit solves this problem with
Scope objects. On initialization scope objects are provided a user and a collection. The
Scope implements a
resolve method which filters the collection. Pretty simple.
class ArticlePolicy < ApplicationPolicy class Scope attr_reader :user, :scope def initialize(user, scope) @user = user @scope = scope end def resolve if user.owner? scope.where(team: user.teams) elsif user.team_admin? scope.where(team: user.current_team) else scope.where(user: user) end end end end
Controllers are provided with a
policy_scope helper method to simplify the call.
policy_scope works similarly to
authorize in looking up the necessary
Scope based on naming convention and calling
class Api::ArticlesController < Api::BaseController def index respond_with :api, articles, each_serializer: ArticleSerializer end private def articles @articles ||= policy_scope Article end end
To avoid hitting the database more than once, it is common to memoize a collection. Pundit encourages using
policy_scope in the views as well as in the controller providing opportunities to inadvertently hit the database multiple times. An optional
cache flag for
policy_scope could mitigate any performance issues while keeping the existing api usable.
def articles policy_scope Article, cache: true end
PolicyFinder is the workhorse of Pundit looking up Policies and Scopes behind the scenes.
find relies on naming conventions to infer what Policy or Scope to load, but the convention does not have to be observed. If the object or its class implements
policy_class that will be used first. Otherwise
find guesses at the resource's class name.
Scopes are found by first
finding the Policy and then looking for a corresponding scope in the Policy namespace. Therefore, if a controller only exposed an
index action it would require a boilerplate Policy to use
policy_scope. Adding a concrete
ScopeFinder and an abstract
Finder would make Scopes a more flexible feature for application developers.
Pundit has an excellent error story as the provided errors help prevent mis-exposure of data. Pundit offers two controller
policy_scope have not been called,
PolicyScopingNotPerformed are raised respectively.
Policy is found but no query method matches the controller action the
NotAuthorizedError is raised. With the
after_actions and the
NotAuthorizedError, Pundit interjects itself into the development process reminding developers of the need to consider authorization logic for each new endpoint and conscientiously opt-out of Pundit's protection.
Pundit is an excellent gem for basic to complex authorization rules. Policies and Scopes tell your application's authorization story clearly and succinctly allowing for easy implementation and maintenance. Its
after_action verification callbacks ensure your future self or other developers do not forget to consider authorization logic for each new endpoint. These two features of ease of use and safety checks makes Pundit an absolute win for any team and their users.
What do y'all think of Pundit?