Skip to content

Role Based Access Control

Chris Laskey edited this page Mar 7, 2019 · 14 revisions

Short Summary

Access control is managed by checking a User's Permissions. A User has Permissions through their associated Role(s).

Authorization is done at the endpoints. Wrap existing logic in provided functions and define which permission(s) are needed. Appropriate error responses are handled automatically:

+ authorize(conn, "users:create", fn () ->
  existing logic
+ end)

Contributors

Name GitHub
Chris Laskey @chrislaskey
Hailey Willis @yourFriendWes

Pattern Overview

The role-based access control pattern uses three core concepts:

  • Users
  • Roles
  • Permissions

Where the following is true:

  • A User may have many associated Roles
  • A Role may have many associated Permissions
  • A User may have many associated Permissions through their associated Roles

The key to the pattern is:

  • Access control is done by checking the collective Permissions of the User
  • Roles are only a semantic way to track logical groupings of Permissions, but do not have meaning themselves

In other words, when authorizing an action the users's permissions are checked, never the user's roles.

A Concrete Example

Let's visualize the pattern with the following example:

1. The `Staff` Role has the permissions:
  - clouds:list
  - customers:list

2. The `Customer Success Manager` Role has the permissions:
  - customers:list
  - customers:show
  - customers:create
  - customers:update
  - customers:delete

The Permissions of a User associated with both Roles would be a combination of both:

- clouds:list
- customers:list
- customers:show
- customers:create
- customers:update
- customers:delete

From an access control standpoint, when the User tries to access the Customer List page it verifies if the user has a customers:list Permission. It does not matter which of the User's Role(s) granted the Permission, only that the Permission exists.

Value Proposition

The value of this access control pattern is:

  • Access levels constantly evolve over the lifespan of an application. By tying access control to permissions, it allows administrators to define semantically meaningful Roles and keep them up-to-date as the use-cases change

  • It keeps permissions flat. No more having to remember what the difference is between an "admin" vs. "site admin" vs. "super admin". Users can be assigned many Roles, and each Role has a clear set of permissions.

  • Permissions can be simple to start. As the application's complexity grows, permissions can scale with it to become increasingly granular. It's important to be able to use the same access control pattern as the application grows without becoming limited by it or having to rewrite it from the ground up.

The downsides of this pattern are:

  • There is more to manage. The overhead of managing users, roles, and permissions is higher than in other access control patterns. However, this can be mitigated in part by Web and API patterns to make managing changes faster and more intuitive.

Implementation

Core Logic

App: artemis

The core application logic lies in apps/artemis.

The database migrations and schema files are defined for:

  • Users
  • Roles
  • Permissions

It also defines Contexts for other applications can use to list / show / create / update / delete records without exposing any implementation details about the database or how the data is stored.

  • User Contexts
  • Role Contexts
  • Permission Contexts

In addition, the Artemis.UserAccess module defines the core code for checking if a user has the required permission(s).

  • User Access Module

Each application endpoint builds on top of these core resources to define their own authorization checks. These are outlined below.

Authorization (Web)

App: artemis_web

In the web application, authorization is done in the controller for each action. Given a standard controller index action:

def index(conn, params) do
  render(conn, "index.html")
end

Authorization is added by wrapping the existing logic in an authorize function. It takes three arguments:

  1. conn which is used to pull out the current user and its permissions
  2. required permission(s)
  3. a function to execute if the permission(s) check passes

An implementation of the pattern looks like:

def index(conn, params) do
+ authorize(conn, "users:list", fn () ->
    render(conn, "index.html")
+  end)
end

If the permission check succeeds, the function passed in as the third argument is executed and the page is rendered.

If the permission check fails, the render_forbidden function is executed, which sets a 403 status code header and renders the 403.html error page.

Note: In addition to authorize which checks for one permissions, there is also authorize_any and authorize_all which check for multiple permissions.

Authorization (API)

App: artemis_api

In the API application, authorization is done in the GraphQL resolver. Given a standard response:

def list(params, context) do
  {:ok, ListUsers.call(params)}
end

Authorization is added by wrapping the existing logic in an authorize function. It takes three arguments:

  1. context which is used to pull out the current user and its permissions
  2. required permission(s)
  3. a function to execute if the permission(s) check passes

An implementation of the pattern looks like:

def list(params, context) do
+  authorize context, "users:list", fn () ->
    {:ok, ListUsers.call(params)}
+  end
end

If the permission check succeeds, the function passed in as the third argument is executed and the page is rendered.

If the permission check fails, a GraphQL error message is returned with the message Unauthorized User.

Note: In addition to authorize which checks for one permissions, there is also authorize_any and authorize_all which check for multiple permissions.

Discussion

Thoughts? Feedback? View the discussion thread for this pattern.