developers

What Is Attribute-Based Access Control (ABAC) and How to Implement It in a Rails API?

There are different ways to implement an authorization system and the one you choose depends on your application's needs. Attribute-Based Access Control (ABAC) is just one of them, so let's go ahead and learn how to implement it in a Rails API.

Previously on your access control series *read with TV show presenter's voice*... You learned about Role-Based Access Control (RBAC) and how to integrate it into your Rails API. In this blog post, you’ll continue learning about different authorization systems, this time about Attribute-Based Access Control and how to migrate the access control of the expense management API from RBAC to ABAC.

Starting from here?

If you want to start this tutorial from the beginning, start from this blog post.

Clone the application repo and check out the

add-rbac
branch:

git clone -b add-rbac https://github.com/auth0-blog/rails-api-authorization

Make the project folder your current directory:

cd rails-api-authorization

Then, install the project dependencies:

bundle install

Then, setup the database

  rails db:setup

Finally, create a

.env
hidden file:

touch .env

Populate

.env
using the
.env.example
file:

cp .env.example .env

Add your Auth0 configuration variables to the .env file and run the project by executing the following command:

rails s

What Are Attributes in the Context of Access Control?

Let's consider the use case of the expense management API you are using in this tutorial. For RBAC, you created an

admin
role and assigned it to users using Auth0 Actions. Users with the
admin
role can approve and review expenses, and so far, that's so good.

The following requirement you have is that only certain users can do certain things on specific expense reports:

  • Users can only see a report they own (submitted by them)
  • Users can only see a report if they are the approver
  • Users who are approvers can only approve reports on weekdays (we don't want people working on weekends!)

With the current RBAC implementation, a user with the

admin
role can do all of these things, but unfortunately, not all admin users need to approve reports. So, as a solution, you think adding these guards to your code sounds good, so you are going to check the attributes of the report (the user is either the approver or submitter of this report), the date the report is being approved, etc.

This practice has a name, and it's called Attribute-Based Access Control.

What Is Attribute-Based Access Control (ABAC)?

According to NIST, Attribute-Based Access Control (ABAC) is an access control method where a subject's requests to perform operations on objects are granted or denied based on assigned attributes of the subject, assigned attributes of the object, environment conditions, and a set of policies that are specified in terms of those attributes and conditions.

In other words, any access decision that your application makes based on attributes of an entity can be considered ABAC and it'll be specific to your application and access control needs.

ABAC also gives you more control and granularity over your access decisions, making it more flexible than RBAC.

Regarding the expense management API, RBAC and ABAC can coexist; in fact, checking for a role or permission is also checking for attributes, in this case, attributes assigned to the user, so we can say RBAC is an implementation of ABAC with a limited scope of roles and permissions. So now, let's say it is a requirement that the user also needs to be an admin to perform the approver actions. Why not? 😛

Let's look at what you'll need to change in your Ruby on Rails API to comply with the new access control requirements and implement ABAC.

ABAC Implementation in a Ruby on Rails API

Let's take a look at the requirements you need to implement:

  • Users can only see a report they own (submitted by them)
  • Only approvers can see reports submitted by other users
  • Only admin users can approve reports
  • Users who are approvers can only approve reports on weekdays (we don't want people working on weekends!)

Based on this, there are a few changes you could make to the application; most of them are related to the

report
entity.

Let's think of the scenario where a user can only see a report they have submitted, or they are the approver; you could think of this as multiple things like the user needs to have some relationship with the expense report, meaning they are either the submitter or the approver.

There's little we can do with the relationship example now. Still, you can define that if a user is the submitter or approver of a report, they have some ownership over it and are allowed to see that report, so let's start there.

In the API context, you can define this ownership such that the user making the request is the same user for which we want to see reports. At the moment, your API doesn't have the context of the user other than an access token, which tells you only that they are authorized to access your API and that they are

admin
because you added this role as a custom claim in the access token.

So, that's great 🫠 how can you get information from the user in your API using Auth0? The answer is by connecting your Auth0 API and your Auth0 Application!

Connect Your Auth0 API with your Auth0 Application

If you have a REST API as your backend, chances are you have a frontend application where your users authenticate and manage their data, etc.

When you use Auth0, your users can authenticate using Universal Login. So, you need to create an Auth0 Application for either a SPA or a Regular Web Application and integrate it with your code using one of the SDKs. For this blog post, let's focus on the example of the regular web application.

When you created the expense management API, you created an Auth0 API and an Auth0 Regular Web Application for testing purposes, well now you are going to use this Regular Web Application because in a real-life scenario, your users will probably authenticate themselves somewhere and once they're authenticated you proceed to do X or Y in your REST API.

In the Regular Web Application you created in Auth0 for your users to authenticate, you need to implement login, logout, etc., and you can learn how to do so by following any of the Developer Center Guides on Authentication. Still, the general process is almost identical in all of them, and the outcome is that you will get an access token from Auth0 issued for the authenticated user.

Then, you need to tell your Regular Web Application that your users will be interacting with your REST API and, therefore, its counterpart in Auth0, so when you request the access token, you need to provide the

audience
as well. An example of how to do this in a Ruby on Rails Web application would be:

Rails.application.config.middleware.use OmniAuth::Builder do
 provider(
 :auth0,
    AUTH0_CONFIG['auth0_client_id'],
    AUTH0_CONFIG['auth0_client_secret'],
    AUTH0_CONFIG['auth0_domain'],
 callback_path: AUTH0_CONFIG['auth0_callback_path'],
 authorize_params: {
 scope: 'openid profile email',
 audience: 'https://rails-api-authorization', # pass the audience ✨
 }
 )
end

Once you pass the

audience
, you'll get a JWT token containing the information about the user, as well as any scopes and claims you've added. To learn more about the audience parameter you can check out this post.

That's all you need to do for now in Auth0 to make this work, so let's get back to the code!

Validate Ownership of the User Making the Request

You already added a method to check for the user's role, so similarly, you could do the same to check for the user's ownership. In the context of the expense management app, we'll say:

A user is allowed to see an expense report of another user if they are the subject of the access token

At the moment, you do not store any information about the user's data in your database, but you will need the Auth0 user's ID to compare it.

Add the Auth0 ID to the User Model

Let's create a migration to add a new field to the User model using the following command on your terminal:

rails g migration AddAuth0IDToUsers auth0_id:string

The command above will generate a new migration file under

db/migrate/YYYYMMDDHHMM_add_auth0_id_to_users.rb
that looks like this:

class AddAuth0IdToUsers < ActiveRecord::Migration[7.0]
  def change
 add_column :users, :auth0_id, :string
  end
end

Then, execute the migration by running

rails db:migrate
on your terminal.

Now, let's add a validation to the User model to make sure the

auth0_id
field is always present and unique. Go to
app/models/user.rb
and add the following code:

class User < ApplicationRecord
 has_many :expenses, foreign_key: :submitter_id
 has_many :submitted_reports, class_name: 'Report', foreign_key: 'submitter_id'
 has_many :reports_to_review, class_name: 'Report', foreign_key: 'approver_id'

 validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, uniqueness: true
  # 👇 new code 
 validates :auth0_id, presence: true, uniqueness: true
end

You're all set from the model level. Let's move to the controller level.

Add the Ownership Validation to the Controller

Go to the

Secured
concern in
app/controllers/concerns/secured.rb
and add the following code:

# frozen_string_literal: true

module Secured
  extend ActiveSupport::Concern

  REQUIRES_AUTHENTICATION = { message: 'Requires authentication' }.freeze
  BAD_CREDENTIALS = {
 message: 'Bad credentials'
 }.freeze
  MALFORMED_AUTHORIZATION_HEADER = {
 error: 'invalid_request',
 error_description: 'Authorization header value must follow this format: Bearer access-token',
 message: 'Bad credentials'
 }.freeze
  INSUFFICIENT_ROLES = {
 error: 'insufficient_roles',
 error_description: 'The access token does not contain the required roles',
 message: 'Permission denied'
 }.freeze
  # 👇 new code 
  NOT_OWNER = {
 error: 'not_owner',
 error_description: 'The access token does not belong to the current user',
 message: 'Permission denied'
 }.freeze
  # 👆 new code 

  def authorize
 token = token_from_request

 validation_response = Auth0Client.validate_token(token)

    @decoded_token = validation_response.decoded_token

    return unless (error = validation_response.error)

 render json: { message: error.message }, status: error.status
  end

  def validate_roles(roles)
    raise 'validate_roles needs to be called with a block' unless block_given?
    return yield if @decoded_token.validate_roles(roles)

 render json: INSUFFICIENT_ROLES, status: :forbidden
  end

  # 👇 new code 
  def validate_ownership(current_user)
    raise 'validate_ownership needs to be called with a block' unless block_given?
    return yield if @decoded_token.validate_user(current_user)

 render json: NOT_OWNER, status: :forbidden
  end
  # 👆 new code 

  private
  # ... 
end

You are adding a new hash called

NOT_OWNER
to specify the error message in case the user is not the owner of the access token being passed. Then, in the
validate_ownership
method, you're checking the access token and returning an error if the user is not the owner. Since you haven't implemented the
validate_user
method yet, let's add it to the token structure.

Go to your

Auth0Client
class in
app/lib/auth0_client.rb
and add the following code:

# frozen_string_literal: true

require 'jwt'
require 'net/http'

class Auth0Client

  # Class members
  Response = Struct.new(:decoded_token, :error)
  Error = Struct.new(:message, :status)
  Token = Struct.new(:token) do
    def validate_roles(roles)
 required_roles = Set.new roles
 token_roles = Set.new token[0][Rails.configuration.auth0.roles]
 required_roles <= token_roles
    end
    # 👇 new code
    def validate_user(current_user)
 current_user.auth0_id == token[0]["sub"]
    end
    # 👆 new code
  end

  # Helper Functions
  # ...
end

The

validate_user
method checks the
current_user
variable (the user from whom you want to check the reports) against the owner of the access token. In the access token, you have information about the user's ID in Auth0 under the
sub
field, so you are comparing that against the
auth0_id
from the local database.

Next, call the

validate_ownership
method in the
ReportsController
. You want to validate the user's ownership when they request to review or submit a report, so let's add the validation to the corresponding actions.

Go to

app/controllers/reports_controller.rb
and add the following code:

class ReportsController < ApplicationController
 before_action :set_report, only: %i[ show approve ]
 before_action :set_user
 before_action :authorize

  # GET users/:user_id/reports/submitted
  def submitted
    # 👇 new code
 validate_ownership(@user) do # user is the owner of these reports
      @reports = Report.where(submitter_id: @user.id) # list only reports where user is submitter

 render json: @reports
    end
    # 👆 new code
  end

  # GET users/:user_id/reports/review
  def review
    # 👇 new code
 validate_ownership(@user) do # user is the owner of these reports
      @reports = Report.where(approver_id: @user.id) # list only reports where user is approver

 render json: @reports
    end
    # 👆 new code
  end

  # GET users/:user_id/reports/1/approve
  # ...
end

By adding the

validate_ownership
block in the controller together with the condition inside the query, you are implementing a form of ABAC because you're allowing or denying access based on certain attributes of the user or the report.

Validate that Only Approver Users can Approve Reports

Even though this might be a new requirement, you already took care of this scenario when you first added RBAC. If you look at the

approve
action in the
ReportsController
:

class ReportsController < ApplicationController
 before_action :set_report, only: %i[ show approve ]
 before_action :set_user
 before_action :authorize

  # GET users/:user_id/reports/submitted
  # ...

  # GET users/:user_id/reports/1/approve
  def approve
 validate_roles [ADMIN] do # if user is admin
      if @report.is_approver?(@user) # if user is the approver for this report
        @report.status = "approved"
        @report.save
 render json: @report
      end
    end
  end

  # GET users/:user_id/reports/1
  #...
end

The check for

@report.is_approver?(@user)
already confirms that
@user
is the approver of
@report
. This is interesting because sometimes we mix up different authorization models without even realizing it, and that's okay as long as it fits your business model. s

Validate that Approvals Only on Weekdays

You only have to add another validation to allow users to approve expense reports on weekdays, and this is possible by adding a new check to the

approve
action in the
ReportsController
. Go to
app/controllers/reports_controller.rb
and add the following code:

class ReportsController < ApplicationController
 before_action :set_report, only: %i[ show approve ]
 before_action :set_user
 before_action :authorize

  # GET users/:user_id/reports/submitted
  #...

  # GET users/:user_id/reports/1/approve
  def approve
 validate_roles [ADMIN] do # if user is admin
      if @report.is_approver?(@user) # if user is the approver for this report
        # 👇 new code 
        if Date.current.on_weekday? # can only approve on weekdays
          @report.status = "approved"
          @report.save
 render json: @report
        else
 render json: {message: "Can only approve on weekdays"}, status: 401
        end
        # 👆 new code 
      end
    end
  end

  # GET users/:user_id/reports/1
  #...
end

The function

Date.current.on_weekday?
returns true if it's a weekday (Mon-Fri) and false otherwise. Users who try to approve during the week will get an error.

At this point, you've fulfilled all the requirements you've received so far! 🚀 It's time to go back to the client and discuss the progress you've made.

Up until here, all the changes in the code are available in the

add-abac
branch of the repository.

What About Policies?

You're introducing a lot of extra complexity in the application code that needs to be recreated by each environment, such as web, mobile, CLI, etc. Using a policy engine can streamline application decision-making.

A policy is a set of rules defining a software service's behavior. There are access control systems like Open Policy Agent (OPA), a full-featured policy engine that offloads policy decisions from your software so you don't have to implement it from scratch, or Policy-Based Access Control (PBAC), where access control decisions are made based on the business roles of users and is combined with policies.

You won't implement PBAC for the sake of this tutorial, but I thought it was interesting for you to know that decision-making engines like these exist.

Oh, No. What's Next?

You are showing your implementation to the client, and they're satisfied, but as usual, they came back with more requirements 🥲

  • Users who are managers can see the reports of their directs, plus the reports of their directs' directs, and so on 😵‍💫

You think of a way to implement this with your current solution and... recursiveness 🥲 you know this is possible, but you also start exploring what options are out there for these scenarios. You bump into Relationship-Based Access Control, which seems interesting 🤔 because you feel like basing your implementation on the relationships of your users could work, but you need to do more research.

Conclusion

When we talk about attributes, we refer to properties of the entities of your application; in the expense management application example, we are talking about attributes of a user or an expense report. Attribute-Based Access Control is an authorization model that allows you to make decisions based on these attributes. ABAC gives you more granularity than RBAC because you can specify access control on specific resources other than a role that usually includes more than one resource.

In the next blog post, you'll learn about ReBAC and its implementations.