Well, actually...
KnowItAll is a small, object-oriented approach to authorization. It knows everything about your application!
More of an architectural pattern for API-focused authorization than properly a dependency, and heavily inspired by Pundit, this gem simply provides a small set of helpers that make applying the pattern easier.
If your application needs to validate pre-requisites before performing certain actions, at the same time providing helpful error messages for the API's clients, all that while using regular magic-less Ruby and object oriented design patterns, KnowItAll is your friend.
- KnowItAll
The assumption made is that each action has its own requirements based on the current context. Some may be related to the current user's permissions in the system, others with the parameters sent, and others yet may even have nothing to do with any input received. Let's say you're building the API for a food delivery app. To be able to checkout, you need to validate the following requirements:
- The user must be signed in;
- The user must have a registered address;
- The registered address must be within a determined radius;
- The cart must contain at least $10 in items;
- The chosen items must be available for delivery;
- The store must be open.
It'd be very helpful for a developer consuming this API if, in case of failure, the API returned an appropriate error message explaining exactly what when wrong, instead of an empty 403 Forbidden
. Performing this manually is easy, but quickly polutes the action's code:
class OrdersController < ApplicationController
def create
return error("User must be signed in") unless current_user
return error("User must have a registered address") unless current_user.address
return error("Registered address is outside the range") unless address_in_range?(current_user.address)
return error("Cart must contain at least $10 in items") unless cart_has_minimum?(cart)
return error("Some of the items are not available") unless items_available?(cart.items)
return error("The store is closed") unless store.open?
# Here finally starts what the action actually does
order = Order.create(order_params)
if order.save
render json: order, status: :created
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
private
def error(message)
render json: { error: message }, status: :forbidden
end
end
It's much more readable, as well as easier to test and extend, if all of those requirement tests were contained in a proper class:
class OrdersController < ApplicationController
def create
policy = OrdersPolicies::Create.new(current_user, cart, store)
return render json: { errors: policy.errors } unless policy.authorize?
order = Order.create(order_params)
if order.save
render json: order, status: :created
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
end
That's exactly the architectural pattern encouraged by this gem. By including a small set of helpers, it makes it extremely simple to perform complex validations and provide helpful feedback through the API.
Pundit is great! I've been using it for years and I love it, but its model-focused permissions and structural pattern makes it difficult and awkward to perform validations on scenarios that need multiple arguments and show appropriate error messages for the API's clients. Based on modifications I've made when using Pundit in some projects, I created this gem.
Add this line to your application's Gemfile:
gem 'know_it_all'
And then execute:
$ bundle
Or install it yourself as:
$ gem install know_it_all
Then include KnowItAll
in your application controller:
class ApplicationController < ActionController::Base
include KnowItAll
end
When adding the gem to your application, you can optionally run:
rails generate know_it_all:install
This will include the KnowItAll
module in your application controller and create a ApplicationPolicy
, which you can use to define some defaults for your policies.
To create a new policy, you can use the following generator:
rails generate know_it_all:policy orders index create update destroy
This will create, in addition to one policy file for each action (app/policies/orders_policies/index.rb
, app/policies/orders_policies/create.rb
, etc.), a app/policies/stores_policy.rb
file containing both the StoresPolicies
module declaration and a StoresPolicies::Base
class to share common behaviors specific to that controller's policies.
There are two steps to using this gem: creating and using policies:
The core of this gem, policies are regular Ruby classes that perform some validation and obey some rules:
- It is initialized with the same arguments that are passed to the
authorize
,authorize!
andauthorize?
methods in the controller; - It responds to a method
errors
; - Calling
errors
returns an object that responds to the methodempty?
and is serializable (so you can render it as the response to the request). It's usually an array ofString
s, but it could easily be anActiveModel::Errors
.
Here's an example:
module OrdersPolicies
class Create
def initialize(current_user, cart, store)
@current_user = current_user
@cart = cart
@store = store
end
def errors
@errors = []
@errors << "User must be signed in" unless @current_user
@errors << "User must have a registered address" unless @current_user.address
@errors << "Registered address is outside the range" unless address_in_range?(current_user.address)
@errors << "Cart must contain at least $10 in items" unless cart_has_minimum?(@cart)
@errors << "Some of the items are not available" unless items_available?(@cart.items)
@errors << "The store is closed" unless @store.open?
end
end
end
Notice that I didn't include any module or extend from any class here. KnowItAll
will simply infer the name of the class (based on the Naming convention) and instantiate it, even if it's a PORO. Now, using ActiveModel::Validations
:
module OrdersPolicies
class Create
include ActiveModel::Validations
validates_presence_of :current_user, :address
validate :address_in_range
validate :cart_has_minimum
validate :items_are_available
validate :store_is_open
def initialize(current_user, cart, store)
@current_user = current_user
@cart = cart
@store = store
run_validations! # Populates the `ActiveModel::Errors`
end
end
end
The convention KnowItAll
uses for defining the name of the constant containing the appropriate policy is the following:
- Based on the
controller_path
method on the controller, it builds a module name by appending thePolicies
suffix:"orders"
becomes"OrdersPolicies"
and"admin/dashboard_panel"
becomes"Admin::DashboardPanelPolicies"
. - Based on the
action_name
method on the controller, it builds a class name:"index"
becomes"Index"
,"increase_inventory"
becomes"IncreaseInventory"
. - By appending the class name to the module name, it tries to find that constant: with
controller_path == "orders"
andaction_name == "Index"
, it looks for aOrdersPolicies::Index
constant.
For more details about how the module and class names are converted, please check the ActiveSupport::Inflector#camelize
method.
If you don't want to write your own policy from the scratch, I've also provided a minimalistic base policy:
module OrdersPolicies
class Create < KnowItAll::Base
validate :user_signed_in?, "User must be signed in"
validate :address_present?, "User must have a registered address"
validate :address_in_range?, "Registered address is outside the range"
validate :cart_has_minimum?, "Cart must contain at least $10 in items"
validate :items_available?, "Some of the items are not available"
validate :store_open?, "The store is closed"
def initialize(current_user, cart, store)
@current_user = current_user
@cart = cart
@store = store
end
end
end
The class method validate
expects a Symbol
representing the name of a predicate and a String
containing the error message in case the predicate fails. The default errors
method returns an array containing the messages for all the validations that didn't pass.
In case you need to define dynamically the error message (e.g. for internationalization), the validate
method also accepts any object that responds to call
, as a lambda. It'll pass the policy itself as an argument:
module OrdersPolicies
class Create < KnowItAll::Base
attr_reader :cart
validate :cart_has_minimum?, -> (policy) {
I18n.t(:cart_minimum_total_error, total: policy.cart.total)
}
def initialize(cart)
@cart = cart
end
end
end
The simplest approach is to include the KnowItAll
module in the controller you want to perform the validation. For this example, let's make the helpers available to all controllers by including it in the ApplicationController
:
class ApplicationController < ActionController::Base
include KnowItAll
end
After that, we can use the helpers in any controller that inherits from ApplicationController
:
class OrdersController < ApplicationController
def create
authorize! current_user, cart, store
order = Order.create(order_params)
if order.save
render json: order, status: :created
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
end
The authorize!
method raises a KnowItAll::NotAuthorized
exception in case the authorization has failed, and contains the instance of the policy used to perform the validation:
class ApplicationController < ActionController::Base
include KnowItAll
rescue_from KnowItAll::NotAuthorized do |exception|
render json: { errors: exception.policy.errors }, status: :forbidden
end
end
This pattern is so common that I've wrote a method that does exactly that:
class ApplicationController < ActionController::Base
include KnowItAll
rescue_from KnowItAll::NotAuthorized, with: :render_not_authorized
end
Alternatively, you can use the bangless form of the authorization method (authorize
), which doesn't raise an exception and returns the errors in the policy:
class OrdersController < ApplicationController
def create
errors = authorize current_user, cart, store
if errors.empty?
order = Order.create(order_params)
if order.save
render json: order, status: :created
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
else
return render json: { errors: errors }, status: :forbidden
end
end
end
You can use the predicate authorize?
to make decisions based on future authorizations in your views. First you need to make the method available as a helper:
class ApplicationController < ActionController::Base
include KnowItAll
helper_method :authorize?
end
Then use it in your views, passing the appropriate overrides (more about that here):
<%= form_for @order do |f| %>
<!-- Form fields -->
<%= f.button "Place order", disabled: authorize?(
@current_user,
@cart,
@store,
controller_path: "orders",
action_name: "create"
) %>
<% end %>
It's possible that you're already using methods with the same names as the ones in the KnowItAll
module: authorize
, authorize?
, authorize!
, policy
, policy_class
, policy_name
, render_not_authorized
or verify_authorized
. In that case, the solution is to include the module in another class, and use it as a collaborator. The only methods KnowItAll
needs to find the correct policies are controller_path
and action_name
:
class Authorizer
include KnowItAll
attr_reader :controller_path, :action_name
def initialize(controller)
@controller_path = controller.controller_path
@action_name = controller.action_name
end
end
class ApplicationController < ActionController::Base
protected
def authorizer
Authorizer.new(self)
end
end
class OrdersController < ApplicationController
def create
authorizer.authorize! current_user, cart, store
# Action's code here
end
end
In that case, I've made available a KnowItAll::Authorizer
class that does exactly that:
class ApplicationController < ActionController::Base
protected
def authorizer
KnowItAll::Authorizer.new(self)
end
end
It's possible to override any of the methods KnowItAll
uses to define the appropriate policy. You can do that in the controller:
class OrdersController < ApplicationController
def create
authorize! current_user, cart, store
# Action's code here
end
def policy_name
"OrdersPolicies::Checkout"
end
end
Or when calling the authorize
, authorize?
or authorize!
methods:
class OrdersController < ApplicationController
def create
authorize! current_user, cart, store, policy_name: "OrdersPolicies::Checkout"
# Action's code here
end
end
The available overrides are: controller_path
, action_name
, policy_name
, policy_class
and policy
(instance of the policy).
While developing a simple feature, it's easy to forget to perform an authorization check. It's helpful during development to know when you forget it, so I've provided a verify_authorized
method that raises a KnowItAll::AuthorizationNotPerformedError
when there were no calls to any one of the authorization methods: authorize
, authorize?
or authorize!
:
class ApplicationController < ActionController::Base
include KnowItAll
after_action :verify_authorized
end
Follow me on Twitter or chat with me on Gitter! :)
As I said in the beginning, this project is heavily inspired by Pundit, so obviously it is an excellent alternative. I've also used for several years the CanCan gem, but it's been abandoned since then; the CanCanCan gem is a continuation that seems to be thriving. For more alternatives, check The Ruby Toolbox page.
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/oddlyfunctional/know_it_all. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.