Skip to main content

Implementing Role Based Access Control in a Web Application

· 7 min read
Karan Kajla
Co-Founder @ Warrant

Access Control is the process of allowing (or disallowing) user access to specific resources or actions in a software system. For example, only allowing certain users access to internal admin pages on a website or only allowing paying users access to a premium feature. There are many approaches to implementing Access Control, but Role Based Access Control (RBAC) is one of the most popular and widely used. In this guide, we'll cover a standard way to implement RBAC and discuss some best practices for implementing Access Control in APIs and web applications.

Overview#

RBAC is an Access Control model in which the ability to access a resource or action in a system is tied to a permission (or policy), and each of those permissions is associated with one or more roles. Every user in the system has one or more roles and thus has all the permissions associated with those roles. This hierarchical structure is fairly intuitive and works really well for managing user access in a wide range of applications such as SaaS & enterprise software, e-commerce websites, company internal apps, and more.

Since Access Control (especially RBAC) is fundamentally a relational problem, it makes sense to use a relational database like PostgreSQL or MySQL to implement it. We'll break down the data model in this guide using SQL.

Users#

Before doing anything else, we need to keep track of users in our application. For the purpose of this guide, we just need a unique id for each user in our system. This id will be used later to associate users with roles. Most applications will also store extra user information like an email, a password, and first & last name.

CREATE TABLE users(    id INT NOT NULL AUTO_INCREMENT,    email VARCHAR(255),    PRIMARY KEY (id));

Permissions#

Once we have users, the first step in implementing RBAC is to define a set of permissions (or policies) and associate each permission with a privileged action in your application. For example, you might define a user:create permission associated with the ability to create a new user in your application. Only users that have the user:create permission will be able create a new user.

To manage permissions, we really only need each permission to have a unique id like users have. This is enough to associate permissions to roles. To make working with permissions more human-friendly, we'll make this id a unique string identifier (i.e. something like user:create) instead of an integer. This will make it easy to understand what each permission represents just by looking at its id.

CREATE TABLE permissions(    id VARCHAR(255) NOT NULL,    PRIMARY KEY (id));

Roles#

Once we have our permissions defined, it's time to group them together in the form of roles. Roles are like personas for the different types of users that access our application. These personas will dictate which permissions should be grouped together for each role. For example, if your application has a free tier with basic features that are available to all users and a premium tier with more powerful features available only to paying users, you might define two different roles: One role called free_tier_user that grants access to all of the basic features and another role called premium_user that grants access to both the basic and premium features.

The data model for roles will look exactly like the model for permissions.

CREATE TABLE roles(    id VARCHAR(255) NOT NULL,    PRIMARY KEY (id));

Role Permissions#

Since every role will have permissions, we need a way to associate permissions to roles. We'll call this relationship a role permission. To represent a role permission, we need to track the id of the role and the id of the permission we're associating together. Note that permissions can belong to multiple roles.

CREATE TABLE role_permissions(    role_id VARCHAR(255) NOT NULL,    permission_id VARCHAR(255) NOT NULL,    PRIMARY KEY (role_id, permission_id));

User Roles#

Since users will have roles, we also need a way to associate users to roles. We'll call this relationship a user role. To represent a user role, we need to track the id of the role and the id of the user we're associating together. Note that users can have multiple roles.

CREATE TABLE user_roles(    role_id VARCHAR(255) NOT NULL,    user_id INT NOT NULL,    PRIMARY KEY (role_id, user_id));

Authorization#

Now that we have a data model to store and associate users, roles, and permissions, we can use permissions to protect access to resources and actions in our system. We need (1) a way to check the data model to figure out if a user has a permission and (2) an easy way to perform these checks anywhere in our application code. This process of validating user access is known as authorization.

Querying Permissions#

We can figure out if a user has a particular permission by querying our data for a relation between the user attempting to gain access and the permission required to gain access.

# Given a user id 15 and a permission id users:create,# we can determine if the user has the required permission by:# (1) Getting the user's role(s)# (2) Checking if any of the user's roles grant them the required permissionSELECT *FROM permissionsINNER JOIN role_permissions    ON permissions.id = role_permissions.permission_idWHERE    permissions.id = "users:create" AND    role_permissions.role_id IN (        SELECT role_id        FROM roles        WHERE user_id = 15    );

The query above will only return a result if the user with id 15 has the users:create permission through one of their roles. If not, it will return an empty result, meaning the user does not have the required permission through any of their roles.

Access Checks in Code#

To make it easy to check for a permission anywhere in your application, you might abstract the query above into a helper class or method that can be called anywhere in your code. In JavaScript, this might look like:

class Authorization {    // Returns true if the user has the    // permission with the given permissionId    static function hasPermission(userId, permissionId) {        // NOTE: Assume getPermissionsForUser runs the SQL query from above        const permissions = getPermissionsForUser(userId);
        return permissions.length > 0;    }}

You can then call this helper method to protect actions in your application:

function createUser(newUser) {    const currentUserId = UserSession.getCurrentUserId();
    if (!Authorization.hasPermission(currentUserId, "users:create")) {        throw new Error("Unauthorized attempt to create a new user!");    }
    //    // logic to create a new user    //}

Best Practices#

Well-implemented Access Control is one of the most effective ways to provide data privacy for users and prevent potential data loss or theft. When implementing an Access Control model like RBAC, it's important to keep a few things in mind:

  • Define permissions based on resources and actions (i.e. users:create). This leads to well-defined permissions that never need to change even if your roles and application logic do.

  • Do not perform access checks based on role. This can be a very rigid approach that makes it hard to change your access model without updating code. Instead, it's better to check for access to permissions since these map directly to the resources and actions in your application.

  • Have a centralized access control service. Since access control is independent of your application's business logic, it's a good idea to separate authorization logic into it's own service. We did this with the Authorization class we implemented in JavaScript above.

Access Control isn't a core focus for most applications, but it's critical to get right. The margin for error is very low, and even a minor issue in authorization logic could expose privileged data and actions to users who shouldn't have access to them. If you don't want to worry about authorization best practices and implementing secure access control, use Warrant to add access control to your application using RBAC or other access models in under 20 lines of code.