developers

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.

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.