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). The Oso Roles feature provides a configuration-based approach to adding role-based access control to your application.

With our latest release, we've added Ruby support for the Oso Roles feature, so you can build RBAC policies with very little engineering effort.

An Oso Roles policy lets you define roles configuration for your models using the Polar language, a language specifically designed for building authorization:

resource(_type: Org, "org", actions, roles) if
    # Define ALL actions that can be taken on orgs:
    actions = ["read", "create_repos", "list_repos",
               "create_role_assignments", "list_role_assignments",
               "update_role_assignments", "delete_role_assignments"] and

    # Define roles that users can have on orgs
    roles = {
        member: {
            permissions: ["read", "list_repos", "list_role_assignments"],
            implies: ["repo:reader"]
        },
        owner: {
            permissions: ["create_repos", "create_role_assignments",
                          "update_role_assignments", "delete_role_assignments"],
            # Imply other roles, possibly on other resources
            implies: ["member", "repo:admin"]
        }
    };

Read on to learn about how to add Oso to your Ruby app, and enable the Oso Roles feature. If you'd like to skip straight to an example of Oso Roles 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 what requests are allowed and what data a user can access. The policy is stored in a Polar file, along with our code.

For now, let's create an empty policy file called app/policy/authorization.polar. We'll fill it in soon.

We'll load our policy file at application start using the OSO.load_file function when our application starts:

# During application start
require 'oso'

OSO = Oso.new
OSO.register_class(User)
OSO.register_class(Org)
OSO.register_class(OrgRole)

# ... register other classes used by the policy
OSO.load_file('app/policy/authorization.polar')

Enable Oso Roles

In order to enable the built-in roles features, we call the OSO.enable_roles method after configuring our policy:

# ... OSO.load_file, etc ...
OSO.enable_roles

Controlling access with roles

Now, let's write our first rules that use role-based access control. To set up Oso Roles, we must:

  1. Add role and resource configurations to our policy.
  2. Use the role_allows method in our policy.
  3. Assign roles to users and define a rule called actor_has_role_for_resource.

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 the resource rule. The Org resource represents an Organization in the example application. Let's walk through the resource definition for Org.

resource(_type: Org, "org", actions, roles) if
        ...

The rule head has 4 parameters:

  • _type is the Ruby class the resource definition is associated with. NOTE: we must have registered this class with OSO.register_class before loading the policy file.
  • "org" is the identifier for this resource type (this can be any string you choose).
  • actions is a list enumerating all the actions that may be performed on the resource.
  • roles is a dictionary defining all the roles for this resource.

In our rule body, we first define the list of available actions for this resource:

resource(_type: Org, "org", actions, roles) if
    actions = ["read", "create_repo"] and
    roles = {
        ...
    };

Now, we define our roles. Roles are defined in a dictionary that maps the role name to a role configuration.

resource(_type: Org, "org", actions, roles) if
    actions = ["read", "create_repo"] and
    roles = {
        member: {
            permissions: ["read"],
        },
        owner: {
            permissions: ["read", "create_repo"],
        }
    };

This resource definition defines two roles:

  • member: Has the read permission.
  • owner: Has the read and create_repo permissions.

Permissions are actions associated with a resource type. A permission can directly reference an action defined in the same resource. Later, we'll see how to leverage relationships between resources to grant a role a permission defined on a different resource.

Adding role_allows to our policy

To allow access based on roles, we add the following allow rule:

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

Oso will now allow use your role definitions to decide which users are allowed to access which resources.

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 actor_has_role_for_resource rule. As an example, we might add a method onto the user that returns a list of roles for that user:

class User
  ORGS = [Org.create, Org.create, Org.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'd add the actor_has_role_for_resource rule to our policy using the following code:

actor_has_role_for_resource(actor, role_name, resource) if
    role in actor.roles and
    role_name = role.name and
    resource = role.resource;

Oso evaluates the actor_has_role_for_resource rule with the same actor passed to the role_allow rule, 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. In this example, our rule checks whether a matching role exists in user.roles.

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

Implying roles

The "owner" role is a more permissive role than "member". It covers all the permissions of "member", with some additional permissions granted ("create_repo" in our example).

Instead of duplicating the permissions, we can represent this relationship in our policy using implied roles.

resource(_type: Org, "org", actions, roles) if
    actions = ["read", "create_repo"] and
    roles = {
        member: {
            permissions: ["read"],
        },
        owner: {
            permissions: ["create_repo"],
            implies: ["member"]
        }
    };

The "owner" role now implies the "member" role. Any user with the "owner" role will be granted all permissions associated with both roles.

Here's the full Org resource definition from our example app (a Github clone):

resource(_type: Org, "org", actions, roles) if
    actions = ["read", "create_repos", "list_repos",
               "create_role_assignments", "list_role_assignments", "update_role_assignments", "delete_role_assignments"] and
    roles = {
        member: {
            permissions: ["read", "list_repos", "list_role_assignments"],
            implies: ["repo:reader"]
        },
        owner: {
            permissions: ["create_repos", "create_role_assignments", "update_role_assignments", "delete_role_assignments"],
            implies: ["member", "repo:admin"]
        }
    };

Notice the "repo:reader" and "repo:admin" implications. These are roles defined on another resource, Repo. Check out the full authorization policy from our Gitclub example app to see how this works in a Rails app.

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.

Get involved in the Oso community

Connect on Slack

Get help from our team, and talk with hundreds of like-minded developers.
Join the Slack

Share the love

Show off the problems you're solving with Oso and how you're leading the charge.

Get Oso Swag

We're sending free Oso swag to users anywhere in the world. Seriously.
Get swag

Get updates from Oso.

We won't spam you. Ever.