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. sValidate 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
returns true if it's a weekday (Mon-Fri) and false otherwise. Users who try to approve during the week will get an error.Date.current.on_weekday?
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
branch of the repository.add-abac
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.