Skip to content

0x7466/active_entry

Repository files navigation

Active Entry Logo

Active Entry - Simple and flexible authentication and authorization

Gem Version Ruby Coverage Maintainability Documentation

Active Entry is a secure way to check for authentication and authorization before an action is performed. It's currently only compatible with Rails. But in later versions will ActiveEntry be Framework independent.

Active Entry works like many other Authorization Systems like Pundit or Action Policy with Policies. However in Active Entry it's all about the method calling the auth mechanism. For every method that needs authentication or authorization, a decision maker method counterpart has to be created in the policy of the class.

Example

Let's say we have an Users controller in our application:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  include ActiveEntry::ControllerConcern   # Glue for the controller and Active Entry

  def index
    pass!   # The auth happens here
    load_users
  end
end

We have to create the UsersPolicy in order for Active Entry to know who is authenticated and authorized and who not.

# app/policies/users_policy.rb
module UsersPolicy
  class Authentication < ActiveEntry::Base::Authentication
    def index?
      Current.user_signed_in?  # Only signed in users are considered to be authenticated.
    end
  end

  class Authorization < ActiveEntry::Base::Authorization
    def index?
      Current.user.admin?  # Only admins are authorized to perform this action
    end
  end
end

Now every time somebody calls the users#index endpoint, he or she has to be signed in and an admin. Otherwise ActiveEntry::NotAuthenticatedError or ActiveEntry::NotAuthorizedError are raised. You can catch them easily in your controller by using Rails' rescue_from.

class ApplicationController < ActionController::Base
  rescue_from ActiveEntry::NotAuthenticatedError, with: :not_authenticated
  rescue_from ActiveEntry::NotAuthorizedError, with: :not_authorized

  def not_authenticated
    flash[:danger] = "Not authenticated. Please sign in."
    redirect_to sign_in_path
  end

  def not_authorized
    flash[:danger] = "Not authorized."
    redirect_to root_path
  end
end

Installation

Add this line to your application's Gemfile:

gem 'active_entry'

Or install it without bundler:

$ gem install active_entry

Run Bundle:

$ bundle

And then install Active Entry:

$ rails g active_entry:install

This will generate app/policies/application_policy.rb.

Usage

Active Entry works with Policies. You can generate policies the following way:

Let's consider the example from above. We have an UsersController and we want a policy for that:

$ rails g policy Users

This generates a policy called UsersPolicy and is located in app/policies/users_policy.rb.

The above generator call would generate something like this, but with a few comments to help you get started:

module UsersPolicy
  class Authentication < ActiveEntry::Base::Authentication
  end

  class Authorization < ActiveEntry::Base::Authorization
  end
end

Verify authentication and authorization

You probably want to control authentication and authorization for every controller action you have in your app. As a safeguard to ensure, that auth is performed in every request and the auth call is not forgotten in development, add the verify_authentication! and verify_authorization! to your ApplicationController:

class ApplicationController < ActionController::Base
  verify_authentication!
  verify_authorization!
  # ...
end

This ensures, that you perform auth in all your controllers and raises errors if not.

Perform authentication and authorization

in order to do the actual authentication and authorization, you have to use authenticate! and authorize! or pass! as in your actions.

class UsersController < ApplicationController
  def authentication_only_action
    authenticate!
  end

  def authorization_only_action
    authorize!
  end

  def both_authentication_and_authorization_action
    pass!
  end
end

If you try to open a page, Active Entry will raise ActiveEntry::DecisionMakerMethodNotDefinedError. This means we have to define the decision makers in our policy.

module UsersPolicy
  class Authentication < ApplicationPolicy::Authentication
    def authentication_only_action?
      success   # == true | Everybody is allowed
    end

    def both_authentication_and_authorization_action?
      success
    end
  end

  class Authorization < ApplicationPolicy::Authorization
    def authorization_only_action?
      success
    end

    def both_authentication_and_authorization_action?
      success
    end
  end
end

Every decision maker ends with an ?. The name has to be the same as the name of the controller action. So index is going to be index?.

In order for Active Entry to not raise an auth error, the decision makers have to return true. In our above example we used success, which simply returns true.

Note: It has to be an explicit true and not just a truthy value. A string or object return value would raise an auth error.

Rescuing from errors

Catch the errors in your controllers to redirect the user or show them a message.

class ApplicationController < ActionController::Base
  # ...

  rescue_from ActiveEntry::NotAuthenticatedError, with: :not_authenticated
  rescue_from ActiveEntry::NotAuthorizedError, with: :not_authorized

  private

  def not_authenticated
    flash[:danger] = "You are not authenticated!"
    redirect_to login_path
  end

  def not_authorized
    flash[:danger] = "You are not authorized to call this action!"
    redirect_to root_path
  end
end

In this example above, the user will be redirected with a flash message. But you can do whatever you want. For example logging.

Authenticate/authorize outside the action

You can authenticate and authorize outside the action:

class UsersController < ApplicationController
  authenticate_now!
  authorize_now!
  # pass_now!  # Does both, authentication and authorization
end

Access control on class level will ensure that every action performs it.

Note: Don't use the class methods if the controller is inherited in other controllers. Best, don't use them at all and use the methods in the actions conciously.

Variables

You can pass variables to the decision maker.

class UsersController < ApplicationController
  def show
    @user = User.find params[:id]
    pass! user: @user
  end
end

You can now access the user object as instance variable in your decision maker.

module Users
  class Authentication < ApplicationPolicy::Authentication
    def show?
      @user  # == <User:Instance>
    end
  end

  class Authorization < ApplicationPolicy::Authorization
    def show?
      @user  # == <User:Instance>
    end
  end
end

Custom error data

If you write something into @error in our decision maker, you can access it in your rescue methods in the controller:

module UsersPolicy
  class Authentication < ApplicationPolicy::Authentication
    def show?
      @error = { code: 100 }
    end
  end

  class Authorization < ApplicationPolicy::Authorization
    def show?
      @error = { code: 100 }
    end
  end
end

class ApplicationController < ActionController::Base
  # ...

  rescue_from ActiveEntry::NotAuthenticatedError, with: :not_authenticated
  rescue_from ActiveEntry::NotAuthorizedError, with: :not_authorized

  private

  def not_authenticated exception
    flash[:danger] = "You are not authenticated! Code: #{exception.error[:code]}"
    redirect_to root_path
  end

  def not_authorized exception
    flash[:danger] = "You are not authorized to call this action! Code: #{exception.error[:code]}"
    redirect_to root_path
  end
end

But you can pass in whatever you want into your error hash.

Testing

You can easily test your policies in RSpec.

We've created some helpers for your tests. Import them first:

# spec/support/active_entry.rb
require "active_entry/rspec"

Now let's start with the generator:

$ rails g rspec:policy Users

This will generate a spec for the UsersPolicy located in spec/policies/users_policy_spec.rb

require "rails_helper"

RSpec.describe UsersPolicy, type: :policy do
  pending "add some examples to (or delete) #{__FILE__}"
end

Now you can easily test every decision maker with the be_authenticated_for and be_authorized_for matchers.

require "rails_helper"

RSpec.describe UsersPolicy, type: :policy do
  describe UsersPolicy::Authentication do
    subject { UsersPolicy::Authentication }

    context "anonymous" do
      it { is_expected.to_not be_authenticated_for :index }
      it { is_expected.to be_authenticated_for :new }
      it { is_expected.to be_authenticated_for :create }
      it { is_expected.to_not be_authenticated_for :edit }
      it { is_expected.to_not be_authenticated_for :update }
      it { is_expected.to_not be_authenticated_for :destroy }
      it { is_expected.to_not be_authenticated_for :restore }
    end

    context "signed in" do
      before { Current.user = build :user }
      
      it { is_expected.to be_authenticated_for :index }
      it { is_expected.to be_authenticated_for :new }
      it { is_expected.to be_authenticated_for :create }
      it { is_expected.to be_authenticated_for :edit }
      it { is_expected.to be_authenticated_for :update }
      it { is_expected.to be_authenticated_for :destroy }
      it { is_expected.to be_authenticated_for :restore }
    end
  end

  describe UsersPolicy::Authorization do
    subject { UsersPolicy::Authorization }

    let(:user) { build :user }
    
    context "anonymous" do
      it { is_expected.to be_authorized_for :index }
      it { is_expected.to be_authorized_for :new }
      it { is_expected.to be_authorized_for :create }
      it { is_expected.to be_authorized_for :show, user: user }
      it { is_expected.to_not be_authorized_for :edit, user: user }
      it { is_expected.to_not be_authorized_for :update, user: user }
      it { is_expected.to_not be_authorized_for :destroy, user: user }
      it { is_expected.to_not be_authorized_for :restore, user: user }
    end

    context "if @user is Current.user" do
      before { Current.user = user }
      
      it { is_expected.to be_authorized_for :show, user: user }
      it { is_expected.to be_authorized_for :edit, user: user }
      it { is_expected.to be_authorized_for :update, user: user }
      it { is_expected.to be_authorized_for :destroy, user: user }
      it { is_expected.to be_authorized_for :restore, user: user }
    end

    context "if @user is not Current.user" do
      before { Current.user = build :user }

      it { is_expected.to be_authorized_for :show, user: user }
      it { is_expected.to_not be_authorized_for :edit, user: user }
      it { is_expected.to_not be_authorized_for :update, user: user }
      it { is_expected.to_not be_authorized_for :destroy, user: user }
      it { is_expected.to_not be_authorized_for :restore, user: user }
    end
  end
end

Differences to Action Policy

Action Policy is an awesome gem which works pretty similar to Active Entry. But there are some differences:

Action Policy expects a performing subject and a target object

class PostPolicy < ApplicationPolicy
  def update?
    # `user` is a performing subject,
    # `record` is a target object (post we want to update)
    user.admin? || (user.id == record.user_id)
  end
end

In Active Entry you can pass in anything you want into the decision maker, which is accessible as instance variables. See Variables.

One strategy is not better than the other. It's just our preference.

Policies in Action Policy are for Resources/Models

If you have a Post model, you have a PostPolicy in Action Policy. In Active Entry you create policies for controllers. So if you have a PostsController, you have a PostsPolicy. We like to build access control logic around controller endpoints.

Action Policy performs only authorization

Active Entry does technically also not provide authentication mechanisms. It's just that you place your authentication logic in an authentication decision maker. We like both authentication and authorization logic in the same place but seperated hence UsersPolicy::Authentication and UsersPolicy::Authorization.

Contributing

Create pull requests on Github and help us to improve this Gem. There are some guidelines to follow:

  • Follow the conventions
  • Test all your implementations
  • Document methods that aren't self-explaining (we are using YARD)

License

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