close icon
FGA

How to Implement Relationship-Based Access Control (ReBAC) in a Ruby On Rails API?

The way to implement an authorization system depends on your application's needs. Let's explore Relationship-Based Access Control (ReBAC) and implement it in a Rails API using OpenFGA.

February 05, 2025

Starting from here?

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

Otherwise, to continue from where we left off, clone the application repo and check out the add-abac branch:

git clone -b add-abac 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

Previously, it was on your access control series (read with TV show presenter voice). You learned about Attribute-Based Access Control and integrating it into your Rails API. In this blog post, youā€™ll continue learning about different authorization systems, this time about Relationship-Based Access Control and how to iterate from ABAC to ReBAC. Youā€™ll also learn about FGA and how it fits into the picture.

What Is Relationship-Based Access Control (ReBAC)?

Relationship-Based Access Control (ReBAC) is an authorization model in which a subjectā€™s access to a certain resource is defined by the relationships between the subject and the resource. Think, for example, about any social network. Usually, users can connect to each other by following each other or sending friend requests, and you can limit what each user can see based on these relationships.

Note that you can use ReBAC along with RBAC and ABAC. Roles and attributes can coexist with relationships in this case; in fact, roles or attributes can be relations šŸ¤Æ, but itā€™s up to your business case to define whether it makes sense to have them all or simply keep one of the other.

ReBAC and Fine-Grained Access Control

Fine-grained access Control or Fine-Grained Authorization refers to the ability to grant specific users permission to perform certain actions on specific resources. These types of authorization systems allow you to scale to millions of objects and users, where permissions can change very rapidly.

Both ReBAC and ABAC can be fine-grained but ReBAC allows you to make decisions on a database that has up-to-date data. ABAC can do this too but in most traditional implementations there's no database. This helps in scenarios where the data that influences the decision changes often.

A Wild OpenFGA Appears!

There are many ways you can implement a Fine-Grained Authorization system; OpenFGA is one of those. Itā€™s open source, and itā€™s inspired by Googleā€™s Zanzibar, Googleā€™s internal authorization system. In a nutshell, the OpenFGA service answers authorization checks by determining whether a relationship exists between an object and a user.

OpenFGA relies on ReBAC. Not only can you define relationships between subjects and objects, but it also facilitates the implementation of RBAC and even ABAC!

ReBAC Implementation with OpenFGA in Your Rails API

Once the concepts are clear, letā€™s go back to the last implementation of our expense management system. The last thing you heard from stakeholders was:

Users who are managers can see the reports of their directs, plus the reports of their directsā€™ directs, and so on šŸ˜µā€šŸ’«

At the moment, the way to implement something like this would be to add a new attribute to the userā€™s table manager_id and recursively check for managers of managers. This is not impossible, but since youā€™ve learned about OpenFGA, you decide itā€™s worth a try since they happen to have a similar case in their examples of an expenses management app.

What You Have Implemented So Far

  • An admin role that can see everything.
  • A submitter user who can only see their reports or where their ID matches the attribute submitter_id of the report.
  • An approver user who can only see the reports where their ID matches the attribute approver_id of the report.

What Youā€™re Changing

Since youā€™re going to introduce a new requirement, you make the following decisions šŸ¤ :

  • Rely on OpenFGA to dictate access rights, meaning every time you need to check for access, youā€™ll use OpenFGA and not the local relationships in your database; these can be used for querying or other tasks.
  • Remove the admin role. If you remember from the initial blog post, this role was created to allow users to approve expenses, but having more granular control allows you to get rid of the role and rely on relationships.
  • You are going to keep the attributes submitter_id and approver_id for local checks and querying. If you see itā€™s necessary, you can implement a task that assures that the relations you have in your database match the ones that live in OpenFGA (this is out of the scope of this blog post).
  • You are going to add a manager_id to the users table to represent the manager relation so your app knows about this relation

Sounds like a lot of work, right? You might have to make some changes in the app, but youā€™ll end up with a better and more robust authorization system.

Defining Relations

Youā€™ve already mentioned some of the relationships youā€™ll have, but letā€™s summarize them. First, you will have two types: user and report. Remember, OpenFGA works with users and objects in your case; users will be users (duh), and objects will be reports. You can define an authorization model as a YAML file, such as:

model
  schema 1.1

type user

type report

...which is incomplete, so you need to define relations for each type. For example, you know that a user can be a manager of another user, so the relation manager is a good candidate. Letā€™s not forget about the recursiveness we discussed earlier. Luckily for us, itā€™s easier to define in OpenFGA, and youā€™ll do it with an implied relationship that youā€™ll call can_manage. This relationship implies that a user can manage another user when they are their manager, or they can manage the manager (and so on recursively). Letā€™s write it down:

model
 schema 1.1

type user
  relations
    define can_manage: manager or can_manage from manager
    define manager: [user]

type report

Now, for the report, youā€™re interested in knowing who the submitter and the approver are, so these are the two relations youā€™ll create. The submitter relation is straightforward, but the approver relation is also implied because if a user is a manager of the submitter, then they can approve it. So letā€™s model that:

model
 schema 1.1

type user
 relations
  define can_manage: manager or can_manage from manager
  define manager: [user]

type report
 relations
  define approver: can_manage from submitter
  define submitter: [user]

Great! So what you just did here was to define your authorization model, which, as you can see, is where you define the relationships between your users and objects to later on make checks and determine access rights.

Next, let's see what to do with this YAML file and where to put it to have a proper authorization model integrated with OpenFGA.

Integrate OpenFGA into a Rails API

The first thing you need to do is to integrate OpenFGA into your Ruby on Rails API. The fastest way to start testing OpenFGA and play around is using Docker. After pulling the Docker image, you can run it with the following:

docker run -p 8080:8080 -p 8081:8081 -p 3000:3000 openfga/openfga run

The playground will run in port 3000 and the API is available in port 8080 and voila! You have your own OpenFGA server running on your local machine.

Now you can copy the authorization model you created in the previous section; you can paste it into the playground's editor to see it there. You can also transform it to JSON format using FGA CLI like so:

fga model transform --file openfga/authorization-model.fga --output-format json > openfga/authorization-model.json

The next thing you need to do is create a store and an authorization model. In OpenFGA, a store is an entity used to organize authorization check data, and each store contains one or more versions of an authorization model, thatā€™s the JSON file you copied from the playground earlier, and where all your relations and types are defined.

First of all, letā€™s store that JSON file somewhere. Create a new file config/openfga_authorization_model.json and paste the content of that JSON file. This way you have your authorization model ready to go when itā€™s time to create it in your OpenFGA server.

But how do you interact with your OpenFGA server? Well, youā€™ll need to interact with the OpenFGA API, which youā€™ll do next.

Implementing an OpenFGAService Class

In order to keep this modular, letā€™s create a service class to interact with the OpenFGA API. This service class will implement calls for the following endpoints:

  • Create a store
  • Create an authorization model
  • Update a relation
  • Perform a check
  • List objects

Letā€™s create a new class. Create a new folder, app/services, and a new file in it, app/services/openfga_service.rb, and add the following content:

require ā€˜net/httpā€™
require ā€˜uriā€™
require ā€˜jsonā€™

class OpenfgaService

  def self.make_post_request(path, body:)
    uri = URI.parse("#{ENV['FGA_API_URL']}/#{path}")
    request = Net::HTTP::Post.new(uri)
    request.content_type = "application/json"
    request.body = body

    Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
 http.request(request)
    end
  end

  def self.create_store
    response = make_post_request("stores", body: { name: "expenses" }.to_json)

    JSON.parse(response.body)[ā€œidā€] if response.code.to_i == 201 
  end

  def self.create_authorization_model(store_id)
    response = make_post_request("stores/#{store_id}/authorization-models", 
    body: File.read(Rails.root.join('config', 'openfga_authorization_model.json')))

    JSON.parse(response.body)[ā€œauthorization_model_idā€] if response.code.to_i == 201
  end

  def self.update_relation(user, relation, object)
    return unless authorization_data

    store_id, authorization_model_id = authorization_data

    body = {
      writes: {
        tuple_keys: [
          {
            user: user,
            relation: relation,
            object: object
          }
        ]
      },
      authorization_model_id: authorization_model_id
    }.to_json

    response = make_post_request("stores/#{store_id}/write", body: body)

    response.body
  end

  def self.check(user, relation, object)
    return false unless authorization_data
        
    store_id, authorization_model_id = authorization_data

    body = {
      authorization_model_id: authorization_model_id,
      tuple_key: {
        user: user,
        relation: relation,
        object: object
      }
    }.to_json
        
    response = make_post_request("stores/#{store_id}/check", body: body)

    response.code.to_i == 200 ? JSON.parse(response.body)["allowed"] : false
  end
  
  def self.list_objects(user, relation)
    return unless authorization_data

    store_id, authorization_model_id = authorization_data

    body = {
      authorization_model_id: authorization_model_id,
      type: ā€œreportā€,
      relation: relation,
      user: "user:#{user.id}",
      context: {},
      consistency: ā€œMINIMIZE_LATENCYā€
    }.to_json

    response = make_post_request("stores/#{store_id}/list-objects", body: body)

    response.code.to_i == 200 ? JSON.parse(response.body)["objects"].map{|obj| obj.split(":")[1].to_i} : []
  end

  # atuhorized if at least one of the relations is allowed: true
  def self.batch_check_relations(user, relations, object)
    return unless authorization_data

 store_id, authorization_model_id = authorization_data
        
 checks = relations.map do |relation|
 {
        "tuple_key": {
          ā€œuserā€: user,
          ā€œrelationā€: relation,
          ā€œobjectā€: object,
 },
      "correlation_id": SecureRandom.uuid 
 }
    end

 response = make_post_request("stores/#{store_id}/batch-check", 
 body: {authorization_model_id: "#{authorization_model_id}", checks: checks}.to_json)

    # # Response: 
    # {
    # ā€œresultsā€: {
    #     { "886224f6-04ae-4b13-bd8e-559c7d3754e1": { "allowed": false }}, # submmiter
    #     { "da452239-a4e0-4791-b5d1-fb3d451ac078": { "allowed": true }}, # approver
    #   }
    # } 
    # in our case if at least one of those is true, then the user can view the report. This is VERY specific for this 
    # use case!
    response.code.to_i == 200 ? JSON.parse(response.body)[ā€œresultā€].values.map{|e| e.values}.flatten.any? : false
  end

  private 
  def self.authorization_data
    authorization = Authorization.first
    [authorization&.store_id, authorization&.model_id] if authorization
  end

end

This class uses Net::HTTP to make requests to the OpenFGA API. Itā€™s basically a Ruby wrapper for making calls to the OpenFGA API. Note that you need to add your FGA_API_URL to your .env file. If using localhost, then the value is http://localhost:8080.

The create_authorization_model function uses your JSON file to create the authorization model.

One thing in particular for this application is how to store the information about your OpenFGA Store and Authorization Model. The function authorization_data retrieves information about the authorization store and model. Youā€™ve decided to store it locally using a table authorizations. To do that, generate a new model and migration using the following command:

rails generate model Authorization store_id:string model_id:string

This will generate a model app/models/authorization.rb, and youā€™re going to add some validations to make sure things stay consistent:

class Authorization < ApplicationRecord
  validates :store_id, presence: true, uniqueness: true
  validates :model_id, presence: true, uniqueness: true
end

And a migration db/migrate/YYYYMMDDHHMMSS_create_authorizations.rb:

class CreateAuthorizations < ActiveRecord::Migration[7.0]
  def change
    create_table :authorizations do |t|
      t.string :store_id
      t.string :model_id

      t.timestamps
    end
  end
end

Youā€™re probably wondering when you stored that authorization data, right? Well, to answer that, Iā€™m going to use the good old: "It depends."

You need to create a store and authorization model only once. Itā€™s up to you and your business to decide how and when to do this. In your case, youā€™ve decided to use a custom rake task.

When you deploy your app and run your server for the first time, for example, you can run this task manually, and it will create the store and authorization model. Another way to do it could be using a Rails Initializer to run after the server runs.

You went with the rake task option, so you create this lovely task file that not only creates a store and an authorization model but also updates relations and performs checks because youā€™re using it for testing it locally:

namespace :openfga do
 desc ā€œCreate a store and authorization modelā€
  task create_store_and_model: :environment do
  store_id = OpenfgaService.create_store
    if store_id
      authorization_model_id = OpenfgaService.create_authorization_model(store_id)
      Authorization.create!(store_id: store_id, model_id: authorization_model_id)
      puts "Store ID: #{store_id}, Authorization Model ID: #{authorization_model_id}"
    else
      puts ā€œFailed to create storeā€
    end
  end

 desc ā€œUpdate relationā€
    task :update_relation, [:user, :relation, :object] => :environment do |_t, args|
    result = OpenfgaService.update_relation("user:#{args[:user]}", args[:relation], "report:#{args[:object]}")
    puts ā€œUpdate relation result: #{result}ā€
  end

 desc ā€œCheck authorizationā€
 task check_authorization: [:user, :relation, :object] => :environment do |_t, args|
    user = "user:#{args[:user]}"
    relation = "#{args[:relation]}"
    object = "report:#{args[:object]}"
    allowed = OpenfgaService.check(user, relation, object)
    puts ā€œAuthorization check: #{allowed}ā€
  end
end

To create a store and model, you can run the following command in your terminal: rails openfga:create_store_and_model.

Now, back to where you were. With the service class, youā€™ve encapsulated all interactions with the OpenFGA API, so letā€™s actually make calls to it!

Add the manager relation to your Rails API

At this point, youā€™ve defined a manager relation for OpenFGA but also want to keep a record in your local database. For that, letā€™s create a new association manager in app/models/user.rb:

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'
  
 has_one  :manager, class_name: 'User', foreign_key: 'manager_id' # šŸ‘ˆ new code

 validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, uniqueness: true
 validates :auth0_id, presence: true, uniqueness: true

Now, letā€™s create a migration to add the manager_id to the users table. In your terminal, run the following command:

rails generate migration AddManagerIdToUsers

This will generate a new file under db/migrate/YYMMDDHHMMSS_add_manager_id_to_users.rb, and youā€™ll need to add the following code:

class AddManagerIdToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :manager_id, :integer  # šŸ‘ˆ new code
  end

Donā€™t forget to run rails db:migrate in your terminal right after!

Managing Relations

You need to create a new relation in your Rails API at two points: when a user is created or updated and when a user submits an expense report.

Letā€™s start when a user is created or updated; this means that you can either pass the manager_id at creation time or update time. To implement this, youā€™ll need to make the following changes in your app/controllers/users_controller.rb:

class UsersController < ApplicationController
 before_action :authorize
 before_action :set_user, only: %i[ show update destroy ]
  
  # GET /users
  def index
    @users = User.all

    render json: @users
  end

  # GET /users/1
  def show
    render json: @user
  end

  # POST /users
  def create
    @user = User.new(user_params)

    if @user.save
      # šŸ‘‡ new code
      update_authorization_manager(user_params[:manager_id], "manager", @user) if user_params[:manager_id]
      render json: @user, status: :created, location: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /users/1
  def update
    if @user.update(user_params)
      # šŸ‘‡ new code
      update_authorization_manager(user_params[:manager_id], "manager", @user) if user_params[:manager_id]
      render json: @user
    else
 render json: @user.errors, status: :unprocessable_entity
    end
  end

  # DELETE /users/1
  def destroy
    @user.destroy
  end

  private
  # Use callbacks to share common setup or constraints between actions.
  def set_user
    @user = User.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def user_params
    params.require(:user).permit(:email, :auth0_id, :manager_id) # šŸ‘ˆ new code
  end
end

At this point, youā€™re allowing a new field, manager_id, to be sent in the controllerā€™s params and then using it to create the relation using a method called update_authorization_manager...which you havenā€™t defined yet, but letā€™s do it.

Similarly to how you implemented the Secured concern when using roles, itā€™ll be nice to have all the OpenFGA things in one place as well and available for all controllers, so letā€™s create a new concern in app/controllers/concerns/authorized.rb:

# frozen_string_literal: true

module Authorized
  extend ActiveSupport::Concern

  def authorized?(user, relations, object)
    if relations.size == 1
      OpenfgaService.check("user:#{user.id}", relation, "report:#{object.id}")
    else
      OpenfgaService.batch_check_relations("user:#{user.id}", relations,"report:#{object.id}")
    end
  end

  def reports(user, relation)
    return [] unless %w[submitter approver].include?(relation)

    OpenfgaService.list_objects(user, relation)
  end

  def update_authorization_manager(manager_id, relation, object)
    OpenfgaService.update_relation("user:#{manager_id}", relation, "user:#{object.id}")
  end

  def update_authorization_submitter(submitter_id, object)
    OpenfgaService.update_relation("user:#{submitter_id}", "submitter", "report:#{object.id}")
  end
end

The Authorized concern is a proxy between your controllers and the OpenfgaService class. Make sure to also add it in your ApplicationController under app/controllers/application_controller.rb like so:

class ApplicationController < ActionController::API
  include Secured
  include Authorized # šŸ‘ˆ new code

  ADMIN='adminā€™
end

Similarly, letā€™s create the submitter relation for when a user submits an expense report. This happens when a new expense is created under app/controllers/expenses_controller.rb:

class ExpensesController < ApplicationController
  before_action :authorize
  before_action :set_user
  before_action :set_expense, only: %i[ show update destroy ]

  # GET users/:user_id/expenses
  def index
    @expenses = Expense.where(submitter_id: @user.id)

    render json: @expenses
  end

  # GET users/:user_id/expenses/1
  def show
    render json: @expense
  end

  # POST users/:user_id/expenses
  def create
    ActiveRecord::Base.transaction do
      @expense = Expense.new(expense_params)
      @expense.submitter_id = @user.id

      if @expense.save
        report = Report.create(expense: @expense, submitter_id: @expense.submitter_id)
         # šŸ‘‡ new code
        update_authorization_submitter(@user.id, report) if report.persisted?
        render json: @expense, status: :created
      else
        render json: @expense.errors, status: :unprocessable_entity
        raise ActiveRecord::Rollback # Rollback the transaction if saving the expense fails
      end
    end
  end

  # PATCH/PUT users/:user_id/expenses/1
  # ... 
end

At this point, if you create new users and expense reports and check your OpenFGA playground, youā€™ll see them in the Tuples section at the bottom left!

Note that you should also make sure that when a user or report is deleted, you update the relation in OpenFGA. For that, you use the same endpoint but pass a deletes object instead of a writes object in the request body. This is out of the scope of this blog post.

Perform Checks for Authorization

There are three primary endpoints where you need to check for authorization, and they all live in the ReportsController in app/controllers/reports_controller.rb:

  • GET users/:user_id/reports/review ā€” see reports that the user hasnā€™t approved yet
  • PUT users/:user_id/reports/:id/approve ā€” approve a report of a userā€™s directs
  • GET users/:user_id/reports/submitted ā€” see userā€™s submitted reports

Open the app/controllers/reports_controller.rb file 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
    # You no longer need to make these checks but only check on OpenFGA
    # āž– 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
    # šŸ‘‡ new code
    @reports = Report.find(reports(@user, "submitter")) 

    render json: @reports if @reports
  end

  # GET users/:user_id/reports/review
  def review
    # You no longer need to make these checks but only check on OpenFGA
    # āž– 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
    # šŸ‘‡ new code
    @reports = Report.find(reports(@user, "approver")).select{|r| r.status != "approved"}

    render json: @reports if @reports   
  end

  # PUT 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
    #     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
    # end

    # šŸ‘‡ new code
    if authorized?(@user, ["approver"], @report)
      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
    else
        render json: {message: ā€œYou donā€™t have permission to approve this reportā€}, status: 401
    end
  end

  # GET users/:user_id/reports/1
  def show
    # šŸ‘‡ new code
    # user can view if they are a submitter or approver of the report 
    if authorized?(@user, ["submitter", "approver"], @report)
      render json: @report
    else
      render json: {message: ā€œYou donā€™t have permission to view this reportā€}, status: 401
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_report
      @report = Report.find(params[:id])
    end

    def set_user
      @user = User.find(params[:user_id])
    end
end

To perform authorization checks, youā€™re using two different approaches. Letā€™s go action by action:

# GET users/:user_id/reports/submitted
def submitted
  @reports = Report.find(reports(@user, "submitter")) 

  render json: @reports if @reports
end

# GET users/:user_id/reports/review
def review
  @reports = Report.find(reports(@user, "approver")).select{|r| r.status != "approved"}

  render json: @reports if @reports   
end

The submitted action queries and shows the reports where the user is a submitter. This check is performed using the reports method, which internally calls the list objects endpoint from OpenFGA. This endpoint returns a list of all the objects of the given type that the user has a relation with, in this case, a submitter relation. Similarly, the review action performs the same check but for the approver relation.

Then the approve action performs a check to verify the user has a relation approver with the report:

# PUT users/:user_id/reports/1/approve
def approve
  if authorized?(@user, ["approver"], @report)
    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
  else
    render json: {message: ā€œYou donā€™t have permission to approve this reportā€}, status: 401
  end
end

Finally, the show action allows a user to see a given report if they have any relation with it, like being a submitter or an approver:

# GET users/:user_id/reports/1
def show
  # šŸ‘‡ new code
  # user can view if they are a submitter or approver of the report 
  if authorized?(@user, ["submitter", "approver"], @report)
    render json: @report
  else
    render json: {message: ā€œYou donā€™t have permission to view this reportā€}, status: 401
  end
end

Cleaning Up šŸ§¹

You can clean up some stuff because you got rid of the admin role and verified ownership using the access token.

Letā€™s begin with the Secured concern in app/controllers/concerns/secured.rb; you can remove 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
  # šŸ§¹NOT_OWNER = {
  # šŸ§¹  error: 'not_owner',
  # šŸ§¹  error_description: ā€˜The access token does not belong to the current userā€™,
  # šŸ§¹  message: ā€˜Permission deniedā€™
  # šŸ§¹}.freeze

  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

  # šŸ§¹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

  private

  def token_from_request
    authorization_header_elements = request.headers['Authorization']&.split
    render json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elements
        unless authorization_header_elements.length == 2
    render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and return
        end
    scheme, token = authorization_header_elements
    render json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'
    token
  end

Because you no longer need to check the userā€™s access token, you can also remove the following code from the app/lib/auth0_client.rb file:

# 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
  # šŸ§¹  def validate_user(current_user)
  # šŸ§¹    current_user.auth0_id == token[0]["sub"]
  # šŸ§¹  end
  # šŸ§¹end
  Token = Struct.new(:token)

  #...

Finally, you can remove the AUTH0_ROLES environment variable from config/auth0.yml

development:
  domain: <%= ENV.fetch('AUTH0_DOMAIN') %>
  audience: <%= ENV.fetch('AUTH0_AUDIENCE') %>
  # šŸ§¹ roles: <%= ENV.fetch('AUTH0_ROLES') %>

production:
  domain: <%= ENV.fetch('AUTH0_DOMAIN') %>
  audience: <%= ENV.fetch('AUTH0_AUDIENCE') %>
  # šŸ§¹ roles: <%= ENV.fetch('AUTH0_ROLES') %>

...and your .env file:

CLIENT_ORIGIN_URL=http://localhost:4040
AUTH0_AUDIENCE=
AUTH0_DOMAIN=
# šŸ§¹ AUTH0_ROLES=
FGA_API_URL=

Conclusion

Throughout this series, you took an expense management application and iterated it to implement different authorization systems.

Starting with no authorization at all, roles using Auth0 and an Auth0 Action are added to add a custom claim to the access token, access control over attributes is implemented, and finally, ReBAC with OpenFGA is implemented.

Overall, you should use the authorization system that best fits your business needs, and itā€™s ok to mix them up and have roles with ReBAC if thatā€™s what your app needs.

Learn more about OpenFGA and OktaFGA in the blog and our docs.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon