Managing user roles in React using CASL!

dwalsh01

Daragh Walsh

Posted on March 16, 2019

Managing user roles in React using CASL!

Also posted on my blog!

So the best place to start when deciding authentication systems is use case. For myself it was being implemented in a team software project as part of my degree.

We had to implement a grants proposal system which required various user interfaces for the different types of users.

The user roles we had in our system were:

  • Researcher

  • Reviewer

  • Admin

Libraries

CASL

From some research online I found CASL (which has a nice ReactJS package). CASL (pronounced castle) is described by the author as:

An isomorphic authorization JavaScript library which restricts what resources a given user is allowed to access.

From reading up on this package it seemed perfect for my use case.

Redux

Needs no introduction really, everyone who uses React knows about Redux. This was what I was most comfortable with for the storage of user information and the various responses to API calls within the application.

 Implementation

I am going to continue on the premise that you have a functional redux store.

Install Packages

To begin, you must first install the CASL packages necessary. To do so run:

npm i @casl/react @casl/ability
Enter fullscreen mode Exit fullscreen mode

Scoping Can

For this section I will be operating with 2 files, ability.js and Can.js. Both these files I have placed in a config folder. For help with file structure, please see this helpful post by Dan Abramov.

Why should we scope Can? Well, if you don't scope it you must pass the ability we are checking against with every Can call (e.g. <Can I="create" a="Post" ability={ability}>, where ability is the abilities we defined in the ability.js file, or wherever you placed the abilities).

Scoping was implemented for cases where you define several abilities in your app or you want to restrict a particular Can component to check abilities using another instance.

I took the implementation for our Can.js file from the docs:

// Can.js
import { createCanBoundTo } from "@casl/react"
import ability from "./ability"

export default createCanBoundTo(ability)
Enter fullscreen mode Exit fullscreen mode

We import our ability (defined in the next section) and scope this particular Can component to handle the those abilities.

 Defining Abilities For User Roles

// Can.js
import { createCanBoundTo } from "@casl/react"
import ability from "./ability"

export default createCanBoundTo(ability)
Enter fullscreen mode Exit fullscreen mode

As you saw above, we import ability, which is where all user permissions are defined. So lets go to that file now. I am going to break it down into sections and then at the end show you the entire file.

//ability.js
import { Ability, AbilityBuilder } from "@casl/ability"
import store from "../store/index"

// Defines how to detect object's type
function subjectName(item) {
  if (!item || typeof item === "string") {
    return item
  }
  return item.__type
}

const ability = new Ability([], { subjectName })
Enter fullscreen mode Exit fullscreen mode

Okay, so what's going on here? The subjectName function takes in the object and will return the property __type of that object if it exists. Otherwise if the item passed is a string it will simply return that string, etc (I.E. if you pass subjectName('Admin') it will return 'Admin').

//ability.js
import { Ability, AbilityBuilder } from "@casl/ability"
import store from "../store/index"

// Defines how to detect object's type
function subjectName(item) {
  if (!item || typeof item === "string") {
    return item
  }
  return item.__type
}

const ability = new Ability([], { subjectName })
Enter fullscreen mode Exit fullscreen mode

Now, what is this? Well, this is one of two ways to define an Ability instance. What we are doing here is defining an empty Ability instance, which will use the provided subjectName to help decide what rules to attach to a particular user.

Next, we will bring in the redux store to get the current logged in user, if there is any:

//ability.js
...
const ability = new Ability([], { subjectName });

let currentAuth;
store.subscribe(() => {
  const prevAuth = currentAuth;
  currentAuth = store.getState().currentUserReducer;
  if (prevAuth !== currentAuth) {
    ability.update(defineRulesFor(currentAuth));
  }
});
Enter fullscreen mode Exit fullscreen mode

Here we are subscribing to changes in the store and will call the ability.update(defineRulesFor(currentAuth)) method with the current user in the store when the store updates the currentUserReducer object. For reference, here's my currentUserReducer object:

//CurrentUserReducer
const initialState = {
  isLoggedIn: null,
  user: null,
  role: "",
  errorMsg: "",
}
Enter fullscreen mode Exit fullscreen mode

But wait, what's the defineRulesFor function? Well, we implement this ourselves. Here we will return the rules for the current user based on their role. Here is our function:

//ability.js
// this is just below store.subscribe()

function defineRulesFor(auth) {
  const { can, rules } = AbilityBuilder.extract()
  if (auth.role === "researcher") {
    can("view", "Proposal")
    can("view", "Draft")
    can("apply", "Proposal")
    can("view", "Profile")
    can("view", "Teams")
  }
  if (auth.role === "admin") {
    can("add", "Proposal")
    can("view", "Proposal")
    can("accept", "Application")
    can("reject", "Application")
    can("view", "PendingReviews")
  }
  if (auth.role === "reviewer") {
    can("review", "Proposal")
  }
  return rules
}
Enter fullscreen mode Exit fullscreen mode

We are using CASL's AbilityBuilder to define the abilities for the user. We are calling the extract() method simply to make things more legible (avoid nesting). Otherwise it would look something like this:

function defineRulesFor(auth) {
  return AbilityBuilder.define((can, cannot) => {
    if (user.role === "researcher") {
      can("view", "Proposal")
      can("view", "Draft")
      can("apply", "Proposal")
      can("view", "Profile")
      can("view", "Teams")
    }
  })
  //etc.
}
Enter fullscreen mode Exit fullscreen mode

So this is just for my personal preference, both are perfectly fine I just find the first option easier to read. All you have to make sure to do (if you are going with option 1) is to return rules at the end of this function.

Now, let's take the researcher role for an example to explain what's going on. We are saying that if the user is a researcher we want them to be able to:

  • View a Proposal
  • View a Draft
  • Apply for a Proposal
  • View a Profile
  • View Teams

The can function will add these abilities to the rules for this user, once we have the rules defined for the user we then return them at the end of the function.

Once that's done we now have to make sure to export the ability we previously defined (and updated the rules accordingly).

//abilty.js
function defineRulesFor(auth) {
  ...
  if (auth.role === "reviewer") {
    can("review", "Proposal")
  }
  return rules
}
export default ability;

Enter fullscreen mode Exit fullscreen mode

Now, we have covered how I specified the role based rules for each role. Let's get to implementing them in the UI!

Checking rules in the UI

I will give two examples here where I have done this, one is which menu items appear in the sidebar for users to click, which takes them to a particular route, and the other is in rendering the routes only if you have the correct role.

Sidebar

We now use the Can component we previously defined (see the Can.js file above) to conditionally render components. Here is the SidebarRoutes component which renders ListItemLink's where you pass the route and text displayed on the menu item:

//SidebarRoutes.jsx
//Other imports here
import Can from '../../config/Can';

...

const SidebarRoutes = ({ classes }) => (
  <List className={classes.root}>
    <ListItemLink text="Home" />
    <Can I="view" a="Profile">
      {() => <ListItemLink route="profile" text="Profile" />}
    </Can>
    <NestedProposals />
  </List>
);
Enter fullscreen mode Exit fullscreen mode

We import the Can component and check if I can view a Profile. If this is true then it will render the ListItemLink, otherwise it simply won't render it.

I do the same thing for the various rules in the NestedProposals component, which a snippet of can be seen below:

//NestedProposals.jsx
...
<Can I="add" a="Proposal">
    {() => (
        <ListItemLink
        route="admin/proposals/add"
        text="Add Proposals"
        className={classes.nested}
        />
    )}
</Can>
<Can I="review" a="Proposal">
    {() => (
        <ListItemLink
        route="proposals/respond"
        text="Respond To Applications"
        className={classes.nested}
        />
    )}
</Can>
...
Enter fullscreen mode Exit fullscreen mode

Essentially the same thing. I check if user roles permit them to do certain things, and if they are allowed I will render the link.

Routes

So again I will give a snippet of my routes.jsx file. Here it is:

//routes.jsx
...
const Routes = () => (
  <Switch>
    <Route exact path="/" component={GridCards} />

    <Route
      path="/profile"
      render={props => (
        <Can I="view" a="Profile">
          {() => <Profile {...props} />}
        </Can>
      )}
    />
</Switch>
...

Enter fullscreen mode Exit fullscreen mode

So we make use of React Router's render prop to let us check the rules of the current user and do the appropriate rendering. As you can see it's pretty much the same across the board for implementation once you have the rules properly defined.

End

Thank you for reading! I would appreciate any input (positive/negative) on my writing to improve it going forward. Any thoughts/queries please feel free to shoot me a DM on Twitter.

Entire ability.js file

/* eslint-disable no-underscore-dangle */
import { Ability, AbilityBuilder } from "@casl/ability"
import store from "../store/index"

// Defines how to detect object's type
function subjectName(item) {
  if (!item || typeof item === "string") {
    return item
  }
  return item.__type
}

const ability = new Ability([], { subjectName })

let currentAuth
store.subscribe(() => {
  const prevAuth = currentAuth
  currentAuth = store.getState().currentUserReducer
  if (prevAuth !== currentAuth) {
    ability.update(defineRulesFor(currentAuth))
  }
})

function defineRulesFor(auth) {
  const { can, rules } = AbilityBuilder.extract()
  if (auth.role === "researcher") {
    can("view", "Proposal")
    can("view", "Draft")
    can("apply", "Proposal")
    can("view", "Profile")
    can("view", "Teams")
  }
  if (auth.role === "admin") {
    can("add", "Proposal")
    can("view", "Proposal")
    can("accept", "Application")
    can("reject", "Application")
    can("view", "PendingReviews")
  }
  if (auth.role === "reviewer") {
    can("review", "Proposal")
  }
  return rules
}

export default ability
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
dwalsh01
Daragh Walsh

Posted on March 16, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related