/rabarber

Simple role-based authorization library for Ruby on Rails

Primary LanguageRubyMIT LicenseMIT

Rabarber: Simplified Authorization for Rails

Gem Version Github Actions badge

Rabarber is a role-based authorization library for Ruby on Rails. It provides a set of tools for managing user roles and defining authorization rules, supports multi-tenancy and comes with audit logging for enhanced security.


Example of Usage:

Consider a CRM system where users with different roles have distinct access levels. For instance, the role accountant can interact with invoices but cannot access marketing information, while the role marketer has access to marketing-related data. Such authorization rules can be easily defined with Rabarber.


And this is how your controller might look with Rabarber:

class TicketsController < ApplicationController
  grant_access roles: :admin

  grant_access action: :index, roles: :manager
  def index
    # ...
  end

  def delete
    # ...
  end
end

This means that admin users can access everything in TicketsController, while manager role can access only index action.

Table of Contents

Gem usage:

Community Resources:

Legal:

Installation

Add the Rabarber gem to your Gemfile:

gem "rabarber"

Install the gem:

bundle install

Next, generate a migration to create tables for storing roles in the database. Make sure to specify the table name of the model representing users in your application as an argument. For instance, if the table name is users, run:

rails g rabarber:roles users

Rabarber supports UUIDs as primary keys. If your application uses UUIDs, add --uuid option to the generator:

rails g rabarber:roles users --uuid

Finally, run the migration to apply the changes to the database:

rails db:migrate

Configuration

If specific customization is required, Rabarber can be configured by using .configure method in an initializer:

Rabarber.configure do |config|
  config.audit_trail_enabled = true
  config.cache_enabled = true
  config.current_user_method = :current_user
  config.must_have_roles = false
end
  • audit_trail_enabled determines whether the audit trail functionality is enabled. The audit trail is enabled by default.
  • cache_enabled determines whether roles are cached to avoid unnecessary database queries. Roles are cached by default. If you need to clear the cache, use Rabarber::Cache.clear method.
  • current_user_method represents the method that returns the currently authenticated user. The default value is :current_user.
  • must_have_roles determines whether a user with no roles can access endpoints permitted to everyone. The default value is false (allowing users without roles to access such endpoints).

Roles

Include Rabarber::HasRoles module in your model representing users in your application:

class User < ApplicationRecord
  include Rabarber::HasRoles
  # ...
end

This adds the following methods:

#assign_roles(*roles, context: nil, create_new: true)

To assign roles, use:

user.assign_roles(:accountant, :marketer)

By default, it will automatically create any roles that don't exist. If you want to assign only existing roles and prevent the creation of new ones, use the method with create_new: false argument:

user.assign_roles(:accountant, :marketer, create_new: false)

The method returns an array of roles assigned to the user.

#revoke_roles(*roles, context: nil)

To revoke roles, use:

user.revoke_roles(:accountant, :marketer)

If the user doesn't have the role you want to revoke, it will be ignored.

The method returns an array of roles assigned to the user.

#has_role?(*roles, context: nil)

To check whether the user has a role, use:

user.has_role?(:accountant, :marketer)

It returns true if the user has at least one role and false otherwise.

#roles(context: nil)

To get the list of roles assigned to the user, use:

user.roles

To manipulate roles directly, you can use Rabarber::Role methods:

.add(role_name, context: nil)

To add a new role, use:

Rabarber::Role.add(:admin)

This will create a new role with the specified name and return true. If the role already exists, it will return false.

.rename(old_role_name, new_role_name, context: nil, force: false)

To rename a role, use:

Rabarber::Role.rename(:admin, :administrator)

The first argument is the old name, and the second argument is the new name. This will rename the role and return true. If the role with a new name already exists, it will return false.

The method won't rename the role and will return false if it is assigned to any user. To force the rename, use the method with force: true argument:

Rabarber::Role.rename(:admin, :administrator, force: true)

.remove(role_name, context: nil, force: false)

To remove a role, use:

Rabarber::Role.remove(:admin)

This will remove the role and return true. If the role doesn't exist, it will return false.

The method won't remove the role and will return false if it is assigned to any user. To force the removal, use the method with force: true argument:

Rabarber::Role.remove(:admin, force: true)

.names(context: nil)

If you need to list the role names available in your application, use:

Rabarber::Role.names

.assignees(role_name, context: nil)

To get all the users to whom the role is assigned, use:

Rabarber::Role.assignees(:admin)

Authorization Rules

Include Rabarber::Authorization module into the controller that needs authorization rules to be applied. Typically, it is ApplicationController, but it can be any controller of your choice.

class ApplicationController < ActionController::Base
  include Rabarber::Authorization
  # ...
end

This adds .grant_access(action: nil, roles: nil, context: nil, if: nil, unless: nil) method which allows you to define the authorization rules.

The most basic usage of the method is as follows:

class Crm::InvoicesController < ApplicationController
  grant_access action: :index, roles: [:accountant, :admin]
  def index
    @invoices = Invoice.all
    @invoices = @invoices.paid if current_user.has_role?(:accountant)
    # ...
  end

  grant_access action: :destroy, roles: :admin
  def destroy
    # ...
  end
end

This grants access to index action for users with accountant or admin role, and access to destroy action for admin users only.

You can also define controller-wide rules (without action argument):

class Crm::BaseController < ApplicationController
  grant_access roles: [:admin, :manager]

  grant_access action: :dashboard, roles: :marketer
  def dashboard
    # ...
  end
end

class Crm::InvoicesController < Crm::BaseController
  grant_access roles: :accountant
  def index
    # ...
  end

  def delete
    # ...
  end
end

This means that admin and manager have access to all the actions inside Crm::BaseController and its children, while accountant role has access only to the actions in Crm::InvoicesController and its possible children. Users with marketer role can only see the dashboard in this example.

Roles can also be omitted:

class OrdersController < ApplicationController
  grant_access
  # ...
end

class InvoicesController < ApplicationController
  grant_access action: :index
  def index
    # ...
  end
end

This allows everyone to access OrdersController and its children and also index action in InvoicesController.

If you've set must_have_roles setting to true, then only the users with at least one role can gain access. This setting can be useful if your requirements are such that users without roles are not allowed to access anything.

Also keep in mind that rules defined in child classes don't override parent rules but rather add to them:

class Crm::BaseController < ApplicationController
  grant_access roles: :admin
  # ...
end

class Crm::InvoicesController < Crm::BaseController
  grant_access roles: :accountant
  # ...
end

This means that Crm::InvoicesController is still accessible to admin but is also accessible to accountant.

Dynamic Authorization Rules

For more complex cases, Rabarber provides dynamic rules:

class Crm::OrdersController < ApplicationController
  grant_access roles: :manager, if: :company_manager?, unless: :fired?

  def index
    # ...
  end

  private

  def company_manager?
    Company.find(params[:company_id]).manager == current_user
  end

  def fired?
    current_user.fired?
  end
end

class Crm::InvoicesController < ApplicationController
  grant_access roles: :senior_accountant

  grant_access action: :index, roles: [:secretary, :accountant], if: -> { InvoicesPolicy.new(current_user).can_access?(:index) }
  def index
    @invoices = Invoice.all
    @invoices = @invoices.where("total < 10000") if current_user.has_role?(:accountant)
    @invoices = @invoices.unpaid if current_user.has_role?(:secretary)
    # ...
  end

  grant_access action: :show, roles: :accountant, unless: -> { Invoice.find(params[:id]).total > 10_000 }
  def show
    # ...
  end
end

You can pass a dynamic rule as if or unless argument. It can be a symbol, in which case the method with that name will be called, or alternatively it can be a proc that will be executed within the context of the controller instance at request time.

You can use only dynamic rules without specifying roles if that suits your needs:

class InvoicesController < ApplicationController
  grant_access action: :index, if: -> { current_user.company == Company.find(params[:company_id]) }
  def index
    # ...
  end
end

This basically allows you to use Rabarber as a policy-based authorization library by calling your own custom policy within a dynamic rule:

class InvoicesController < ApplicationController
  grant_access action: :index, if: -> { InvoicesPolicy.new(current_user).can_access?(:index) }
  def index
    # ...
  end
end

Context / Multi-tenancy

Rabarber supports multi-tenancy by providing a context feature. This allows you to define and authorize roles and rules within a specific context.

Every Rabarber method can accept a context as an additional keyword argument. By default, the context is set to nil, meaning the roles are global. Thus, all examples from other sections of this README are valid for global roles. Apart from being global, the context can be an instance of ActiveRecord model or a class.

E.g., consider a model named Project, where each project has its owner and regular members. Roles can be defined like this:

  user.assign_roles(:owner, context: project)
  another_user.assign_roles(:member, context: project)

Then the roles can be verified:

  user.has_role?(:owner, context: project)
  another_user.has_role?(:member, context: project)

A role can also be added using a class as a context, e.g., for project admins who can manage all projects:

  user.assign_roles(:admin, context: Project)

And then it can also be verified:

  user.has_role?(:admin, context: Project)

In authorization rules, the context can be used in the same way, but it also can be a proc or a symbol (similar to dynamic rules):

class ProjectsController < ApplicationController
  grant_access roles: :admin, context: Project

  grant_access action: :show, roles: :member, context: :project
  def show
    # ...
  end

  grant_access action: :update, roles: :owner, context: -> { Project.find(params[:id]) }
  def update
    # ...
  end

  private

  def project
    Project.find(params[:id])
  end
end

It's important to note that role names are not unique globally but are unique within the scope of their context. E.g., user.assign_roles(:admin, context: Project) and user.assign_roles(:admin) assign different roles to the user. The same as Rabarber::Role.add(:admin, context: Project) and Rabarber::Role.add(:admin) create different roles.

If you want to see all the roles assigned to a user within a specific context, you can use:

  user.roles(context: project)

Or if you want to get all the roles available in a specific context, you can use:

  Rabarber::Role.names(context: Project)

When Unauthorized

By default, in the event of an unauthorized attempt, Rabarber redirects the user back if the request format is HTML (with fallback to the root path), and returns a 401 (Unauthorized) status code otherwise.

This behavior can be customized by overriding private when_unauthorized method:

class ApplicationController < ActionController::Base
  include Rabarber::Authorization

  # ...

  private

  def when_unauthorized
    head :not_found # pretend the page doesn't exist
  end
end

The method can be overridden in different controllers, providing flexibility in handling unauthorized access attempts.

Skip Authorization

To skip authorization, use .skip_authorization(options = {}) method:

class TicketsController < ApplicationController
  skip_authorization only: :index
  # ...
end

This method accepts the same options as skip_before_action method in Rails.

View Helpers

Rabarber also provides a couple of helpers that can be used in views: visible_to(*roles, context: nil, &block) and hidden_from(*roles, context: nil, &block). To use them, simply include Rabarber::Helpers in the desired helper. Usually it is ApplicationHelper, but it can be any helper of your choice.

module ApplicationHelper
  include Rabarber::Helpers
  # ...
end

The usage is straightforward:

<%= visible_to(:admin, :manager) do %>
  <p>Visible only to admins and managers</p>
<% end %>
<%= hidden_from(:accountant) do %>
  <p>Accountant cannot see this</p>
<% end %>

Audit Trail

Rabarber supports audit trail, which provides a record of user access control activity. This feature logs the following events:

  • Role assignments to users
  • Role revocations from users
  • Unauthorized access attempts

The logs are written to the file log/rabarber_audit.log unless the audit_trail_enabled configuration option is set to false.

Problems?

Facing a problem or want to suggest an enhancement?

  • Open a Discussion: If you have a question, experience difficulties using the gem, or have a suggestion for improvements, feel free to use the Discussions section.

Encountered a bug?

  • Create an Issue: If you've identified a bug, please create an issue. Be sure to provide detailed information about the problem, including the steps to reproduce it.
  • Contribute a Solution: Found a fix for the issue? Feel free to create a pull request with your changes.

Contributing

Before creating an issue or a pull request, please read the contributing guidelines.

Code of Conduct

Everyone interacting in the Rabarber project is expected to follow the code of conduct.

License

The gem is available as open source under the terms of the MIT License.