How to manage dynamic permissions using CASL & Redux

What is CASL?

CASL is a JavaScript library that allows you to manage the permissions of a user based on their role.

Why handle permissions in the front-end?

One of our roles as front-end developers is to reduce the number of requests sent to the server.

For example, we do front-end validations of a form. This means we don’t have to request the server with the data and cause the server to reply with validation errors.

We also manage permissions in the front end, so the user doesn’t have to request certain APIs for which they do not have permission. Eventually, we reduce the load on the server and for the user.

This does not mean we eliminate requesting unauthorized permissions APIs totally. We still need some of them in particular cases.

1. Getting started

You can download the project repo here. You can find the final result here.

  1. Create a react app.
npx create-react-app casl-app
  1. Install Redux, react-redux, and redux-thunk
npm install redux react-redux redux-thunk
  1. Install CASL
npm install @casl/react @casl/ability

2. Creating can file

Create a new file, name it can.js, and paste the following:

import { Ability, AbilityBuilder } from "@casl/ability";
import { store } from "../redux/storeConfig/store";
const ability = new Ability();
export default (action, subject) => {
return ability.can(action, subject);
};

Here we are importing Ability and AbilityBuilder from @casl/ability.

Then we are creating a new instance from the Ability().

After that, we are exporting a default function that we will use later to check for the permission of the logged-in user.

3. Subscribing to the store

Import your store and subscribe to it inside can.js.

import { Ability, AbilityBuilder } from "@casl/ability";
import { store } from "../redux/storeConfig/store";
const ability = new Ability();
export default (action, subject) => {
return ability.can(action, subject);
};
store.subscribe(() => {
let auth = store.getState().auth;
});

Here, we get auth from the store.

We can also see the redux folder and files.

|--redux
   |-- auth
      |-- authActions.js
      |-- authReducer.js
   |-- storeConfig
      |-- store.js
   |-- rootReducer.js

export const login = (user) => async (dispatch) => {
dispatch({
type: "LOGIN",
payload: {
id: 1,
name: "Youssef",
permissions: ["add_users", "delete_users"],
},
});
};
export const logout = () => async (dispatch) => {
dispatch({
type: "LOGOUT",
});
};
const INITIAL_STATE = {};
const authReducer = (state = INITIAL_STATE, action) => {
switch (action.type) {
case "LOGIN":
return { ...state, ...action.payload };
case "LOGOUT":
return {};
default:
return state;
}
};
export default authReducer;
import { createStore, applyMiddleware, compose } from "redux";
import createDebounce from "redux-debounced";
import thunk from "redux-thunk";
import rootReducer from "../rootReducer";
const middlewares = [thunk, createDebounce()];
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
{},
composeEnhancers(applyMiddleware(...middlewares))
);
export { store };
import { combineReducers } from "redux";
import authReducer from "./auth/authReducer";
const rootReducer = combineReducers({
auth: authReducer,
});
export default rootReducer;

In the login action, we code the payload with an object of id, name, and permissions array.

4. Add defineRulesFor function in can.js

import { Ability, AbilityBuilder } from "@casl/ability";
import { store } from "../redux/storeConfig/store";
const ability = new Ability();
export default (action, subject) => {
return ability.can(action, subject);
};
store.subscribe(() => {
let auth = store.getState().auth;
ability.update(defineRulesFor(auth));
});
const defineRulesFor = (auth) => {
const permissions = auth.permissions;
const { can, rules } = new AbilityBuilder();
// This logic depends on how the
// server sends you the permissions array
if (permissions) {
permissions.forEach((p) => {
let per = p.split("_");
can(per[0], per[1]);
});
}
return rules;
};

We create the defineRulesFor function. It takes auth as an argument, which we can get from the store we are subscribing to it.

Next, we add ability.update(defineRulesFor(auth)) to the store.subscribe() body.

Then, we add can and rules from new AbilityBuilder()

The permissions array is a number of strings separated by _:

permissions: ["add_users", "delete_users"]

We split those strings and pass the action and the subject to the can function.

This logic might change if the server, for examples, only sends IDs:

const permissions = [2, 3, 5, 7];
if (permissions) {
  permissions.forEach((p) => {
    if (p === 3) can("add", "users");
    if (p === 7) can("delete", "users");
  });
}

Or maybe a pre-defined role.

const role = "Editor";
if (role === "Editor") {
  can("add", "users");
  can("delete", "users");
}

And so on.

5. Checking permissions

We check permissions inside App.jsx.

import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { login, logout } from "./redux/auth/authActions";
import CAN from "./casl/can";
export default () => {
const dispatch = useDispatch();
const { auth } = useSelector((state) => state);
// rerender the component when `auth` changes
useState(() => {}, [auth]);
return (
<React.Fragment>
<h1>Welcome, {auth?.name || "Please Login!"}</h1>
{CAN("add", "users") && (
<button
onClick={() => {
alert("User Added!");
}}>
Add User
</button>
)}
{CAN("delete", "users") && (
<button
onClick={() => {
alert("User Deleted!");
}}>
Delete User
</button>
)}
<div>
<button
onClick={() => {
dispatch(login());
}}>
Login
</button>
<button
onClick={() => {
dispatch(logout());
}}>
Logout
</button>
</div>
</React.Fragment>
);
};

Here, we display the buttons based on the permission of the logged-in user.

We need to rerender the component when the auth changes using useEffect.

Check the final result here.

Attributions:
  1. undefined by undefined