Guide to Role-Based Access Control (RBAC) in Ruby

Graham Kaemmer

Oso is a batteries-included library for adding authorization to your application. It lets you write policies that define who can do what in your app.

When managing access to resources in an application, it can be useful to group permissions into roles, and assign these roles to users. This is known as Role-Based Access Control (RBAC). This lets you express your authorization logic as two pieces:

  1. The policy, which grants permissions to particular roles. This is where you express rules like "members can create repositories" or "owners can update role assignments".
  2. Role assignments, which are determined by your application data. Often this is expressed as a roles table in your database, but you can also assign roles using relationships that already exist in your data.

Oso provides a configuration-based approach to adding role-based access control to your application. An Oso policy lets you configure roles for your models using the Polar language, a language specifically designed for building authorization:

resource Organization {
  # Define ALL actions that can be taken on organizations
  permissions = ["read", "create_repo", "manage_role_assignments"];
  # Define organization roles that users can have
  roles = ["member", "owner"];

  # Member permissions
  "read" if "member";
  "create_repo" if "member";

  # Owners can manage role assignments
  "manage_role_assignments" if "owner";

  # The owner role implies the member role
  "member" if "owner";
}

Read on to learn about how to add Oso to your Ruby app and connect it with your application data. If you'd like to skip straight to an example of Oso used in an app, check out our Rails example app, a Github clone written with authorization in mind.

Setting up the Oso instance

First, we'll cover some of the basics of integrating Oso into an application.

The Oso class is the entrypoint to using Oso in our application. We usually will have a global instance that is created during application initialization and shared across requests.

Loading our policy

Oso uses the Polar language to define authorization policies. An authorization policy specifies which actions are allowed and which data a user can access. The policy is defined in a Polar file, which lives alongside our code.

We'll start with an empty file called app/policy/authorization.polar. Load the policy at application start using the OSO.load_files function:

# During application start
require 'oso'

OSO = Oso.new
OSO.register_class(User)
OSO.register_class(Organization)
# ... register other classes used by the policy

OSO.load_files(['app/policy/authorization.polar'])

Controlling access with roles

Now, let's add role-based access control to our app. To set up roles in Oso, we must:

  1. Add role and resource configurations to our policy.
  2. Assign roles to users by defining a rule in our policy called has_role.

Configuring our first resource

Roles in Oso are scoped to resources. A role is a grouping of permissions: the actions that may be performed on that resource. Roles are assigned to actors (e.g., users) to grant them all the permissions the role has.

We define resources in Polar using resource blocks. The Organization resource represents an Organization in the our example application. Let's walk through the resource definition for Organization. Insert the following code into app/policy/authorization.polar:

actor User {}

resource Organization {
  # TODO: add permissions, roles, and permission assignments
}

NOTE: in order to define a resource block for the Organization type, we must already have registered an Organization class using register_class, as we did above.

In our resource block, we first define the list of available actions for this resource (also called "permissions"). Our example is based loosely on a Github clone, so for now the available actions on organizations are read, create_repo, and manage_role_assignments:

actor User {}

resource Organization {
  permissions = ["read", "create_repo", "manage_role_assignments"];

  # TODO: add roles and permission assignments
}

Now, we define our roles and the permissions granted to each role:

actor User {}

resource Organization {
  permission = ["read", "create_repo", "manage_role_assignments"];
  roles = ["member", "owner"];

  # Member permissions
  "read" if "member";
  "create_repo" if "member";

  # Owners can manage role assignments
  "manage_role_assignments" if "owner";

  # The owner role implies the member role
  "member" if "owner";
}

This resource definition defines two roles:

  • member: Has the read and create_repo permission.
  • owner: Has the manage_role_assignments permission, and also inherits the member role which grants read and create_repo permissions.

Adding has_permission to our policy

To enable resource block syntax, we add an allow rule referencing the has_permission rule:

allow(actor, action, resource) if
  has_permission(actor, action, resource);

Oso will now allow use our resource's permissions to decide which actions are allowed in the app.

Assigning roles to users

Now we've configured Org roles and set up our policy. For users to have access, we must assign them roles.

We use our own data models to build roles with Oso. We just need to tell Oso what roles a user has for a particular resource through the has_role rule. As an example, we might add a method onto the user that returns a list of roles for that user:

class User
  ORGS = [Organization.create, Organization.create, Organization.create]

  ROLES = {
    "alice": [
      {"name": "member", "resource": ORGS[0]},
      {"name": "owner", "resource": ORGS[1]}
    ],
    "bob": [
      {"name": "owner", "resource": ORGS[2]}
    ]
  }

  attr_reader :name

  def initialize(name)
    @name = name
  end

  # This could also return records from a database!
  def roles
    ROLES[name]
  end
end

And we add the has_role rule to our policy using the following code:

has_role(actor: User, role_name: String, resource: Organization) if
  role in actor.roles and
  role_name = role.name and
  resource = role.resource;

Oso evaluates the has_role rule with actor bound to the same actor that we call the allow rule with, typically an instance of some User model. It should return a true result when the user does indeed have the role_name role for the given resource.

With the has_role rule implemented in your policy, we now have role-based access control enabled! When our app calls OSO.authorize(actor, action, resource), the has_permission rule will check whether the user has a role that grants them permission to perform action on the given resource.

An example of calling OSO.authorize in a controller:

class OrganizationsController
    def show
    organization = Organization.find(params[:id])
    # Ensure current_user has the "read" permission, raise if not
    OSO.authorize(current_user, "read", organization)

    organization.to_json
    end
end

Our full policy in authorization.polar is below:

allow(actor, action, resource) if
  has_permission(actor, action, resource);

actor User {}

resource Organization {
  permission = ["read", "create_repo", "manage_role_assignments"];
  roles = ["member", "owner"];

  # Member permissions
  "read" if "member";
  "create_repo" if "member";

  # Owners can manage role assignments
  "manage_role_assignments" if "owner";

  # The owner role implies the member role
  "member" if "owner";
}

has_role(actor: User, role_name: String, resource: Organization) if
  role in actor.roles and
  role_name = role.name and
  resource = role.resource;

Check out a complete example

The example we defined here is quite basic: we only defined three permission on organizations, and we hard-coded the roles for each user into our User class. In a real app, we have a number of different resources whose permissions might depend on each other, and we have data that we need to load from a database.

For a more comprehensive example of building RBAC in Ruby using Oso, check out Gitclub, a clone of Github's authorization model. It contains permissions on organizations, repositories, and issues. It also has two types of roles: organization roles and repository roles, which can be assigned separately.

Where to go next?

If you want to learn more about integrating Oso into your Ruby app, here are some resources to get started:

If at any point you get stuck, drop into our Slack and we'll unblock you.

We're also happy to schedule a 1x1 with an Oso engineer to help you get started.

Want us to remind you?

We'll email you before the event with a friendly reminder.

The best way to learn is to get your hands dirty.