Roles with persisted permissions
This recipe targets backend developers. We use TypeScript and RDBMS in this guide, so the basic knowledge of both is required.
The issue
The application can be used by multiple users with different roles. Roles' permissions should be configurable through API (e.g., REST API or User Interface). So, we don't need to change or redeploy the app in order to change permissions.
This is also known as RBAC (Role based access control).
The solution
In order to achieve this, we need to store permissions in the database.
First of all, we need to gather all possible actions and subjects, in order to validate them before saving permission to the database.
When all combinations of actions and subjects are known, we can create a table for roles. It should have at least 2 columns: id and name. Then we need to choose one of 2 options to connect roles with permissions:
- Add
permissionscolumn of JSON (or TEXT) data type intorolesand store all rules in that single column.
The main advantage is that it's very simple to manage and retrieve. The main disadvantage is that most ORMs doesn't support partial updates of JSON (and especially TEXT) column, instead they update the whole value as a primitive value. This may lead to unexpected results if several users update permissions of the same role at the same time (the last one will reset changes of others). - Create a separate table for
permissionswhich hasaction,subject,conditionsandroleIdfields.
The main advantage is that you can do partial updates using regular SQL. The main disadvantage more complicated queries (you need to joinrolesandpermissions) and bigger eventual database size.
We will use the 1st option in this guide because permissions are not changed often, so the risk of conflict is acceptable for us. Moreover this permissions field will contain an array that is acceptable by Ability instance (i.e., RawRuleOf<AppAbility>[]).
If it's not acceptable for your situation, use raw SQL syntax of your RDBMS to partially update JSON column or use 2nd option.
We also need a table for users. Every user may have only 1 role, in other words every row in users table should have roleId field.
Having users, roles and permissions, we can create Ability instance for every user request. The logic for the REST API is the following:
- User sends request to access some resources.
- If it's not authenticated, sends back an error that he needs to login.
- If it's authenticated, the app fetches it together with permissions from the database.
- The app creates an
Abilityinstance based on user's permissions - Using
Abilityinstance, the app checks whether user can do a particular action on requested resource. - If not, it's sends error back that user has no permission to do what he attempted to do.
- If user has permissions, then proceed with the actual action.
Enough theory, let's see some examples!
In the next Demo, we are not going to implement REST API as the main intention of this recipe is to solve The issue.
Demo
To make things simple, we will use SQLite database. This also allows to quickly setup the demo on local machine and even run it in a browser.
As a domain, we will use blog application. This is the most known domain, so we won't spent time explaining its details and instead will concentrate on the actual issue.
In our blog, we have 2 roles: member (any registered user) and admin. So, admin can do everything and member:
- can read all articles
- can manage own articles
We will use knex SQL builder to work with the database.
We are not going to walk through knex's API and usage, so if you are not familiar with it, take some time to look through its documentation. Anyway, don't worry, it's quite expressive.
So, let's define the table structure for our database. It includes 3 tables: users, roles and articles. Also we will seed our database with default records:
- 2 roles: admin and member
- 2 users: 1 admin and 1 member
You can find the migration and seeds below:
Initial migration
exports.up = function(knex) {
return knex.schema
.createTable('users', (table) => {
table.increments('id');
table.string('email', 255).notNullable();
table.string('password', 50).notNullable();
table.integer('roleId').unsigned().notNullable();
table.foreign('roleId').references('id').inTable('roles');
})
.createTable('articles', (table) => {
table.increments('id');
table.string('title', 255).notNullable();
table.string('description').notNullable();
table.integer('authorId').unsigned().notNullable();
table.foreign('authorId').references('id').inTable('users');
})
.createTable('roles', (table) => {
table.increments('id');
table.string('name', 255).notNullable();
table.json('permissions').notNullable();
});
};
exports.down = function(knex) {
return knex.schema
.dropTable('users')
.dropTable('articles')
.dropTable('roles');
};
and initial seed:
exports.seed = async (knex) => {
await Promise.all([
knex('users').del(),
knex('roles').del()
]);
await knex('roles').insert([
{
id: 1,
name: 'admin',
permissions: JSON.stringify([
{ action: 'manage', subject: 'all' }
])
},
{
id: 2,
name: 'member',
permissions: JSON.stringify([
{ action: 'read', subject: 'Article' },
{ action: 'manage', subject: 'Article', conditions: { authorId: '${user.id}' } },
])
}
]);
await knex('users').insert([
{ id: 1, email: 'admin@casl.io', password: '123456', roleId: 1 },
{ id: 2, email: 'member@casl.io', password: '123456', roleId: 2 },
]);
};
We used
${user.id}inconditionsproperty of permissions. This is just a placeholder which we will replace it later with the id of a particular user.
Now, let's define all possible actions and subjects:
import { createMongoAbility, MongoAbility, RawRuleOf, ForcedSubject } from '@casl/ability';
export const actions = ['manage', 'create', 'read', 'update', 'delete'] as const;
export const subjects = ['Article', 'all'] as const;
export type Abilities = [
typeof actions[number],
typeof subjects[number] | ForcedSubject<Exclude<typeof subjects[number], 'all'>>
];
export type AppAbility = MongoAbility<Abilities>;
export const createAbility = (rules: RawRuleOf<AppAbility>[]) => createMongoAbility<AppAbility>(rules);
See TypeScript support to get details about type helpers.
typeof actions[number] converts readonly array into union of its values, this allows to reuse actions and subjects defined in value scope inside type scope. We also export createAbility function to make it easier to create Ability instance with bound generic parameters.
In order to work with our database on higher level, we need to create services.
Services
We need to create 2 services: one to fetch users and another to manage articles.
Let's start from a function that will allow us to fetch a single user by some conditions from the database. This function also will prepare user's permissions, so we can directly pass them in createAbility function:
import db from '../db';
import { User } from '../models/User';
import interpolate from '../helpers/interpolate';
export async function findBy(where: Partial<Record<keyof User, any>>) {
const { permissions, ...user } = await db<User>('users')
.innerJoin('roles', 'users.roleId', 'roles.id')
.select('users.id', 'users.email', 'roles.permissions', { role: 'roles.name' })
.where(where)
.first();
user.permissions = interpolate(permissions, { user });
return user;
}
Let's go line by line to understand what happens in the function:
-
we import
db, this is a knex connection instance -
we import
User, this is just an interface for users that has the next shape:import { RawRuleOf } from '@casl/ability'; import { AppAbility } from '../services/appAbility'; export interface User { id: number email: string role: string permissions: RawRuleOf<AppAbility>[] }As you can see, its permissions property has the type that
Abilityinstance accepts in the first parameter. This was done intentionally to simplify ability creation. -
we import
interpolatefunction, this function takes a JSON template and replaces all placeholder (e.g.,${user.id}) with the provided variables inside context. This function usesreviverargument ofJSON.parsemethod to iterate deeply over the object but it's not important for this guide. You can use any template library you like (e.g., mustache, underscore template). -
in
findByfunction, we join users and roles to get user's permissions and role and preprocess permissions' template with theinterpolatefunction (so that, it replaces${user.id}placeholders with the actualuser.idvalue).
The service for articles is more complicated, it will check users' ability to do a particular action on the provided article instance. For the sake of simplicity, we'll show only create function (all the rest are similar):
import { ForbiddenError, subject } from '@casl/ability';
import { AppAbility } from './appAbility';
import db from '../db';
import { Article } from '../models/Article';
const articles = () => db<Article>('articles');
export async function create(ability: AppAbility, partialArticle: Omit<Article, 'id'>) {
ForbiddenError.from(ability).throwUnlessCan('create', subject('Article', partialArticle));
const [id] = await articles().insert(partialArticle);
return { id, ...partialArticle } as Article;
}
// other functions
In this service, we use ForbiddenError class which allows to throw an error if user doesn't have an ability to do something. We also wrap partialArticle with subject call. This allows Ability instance to detect subject type of a plain object.
See Subject type detection for details.
Article is an interface that enforces the shape of our articles:
export interface Article {
id: number
title: string
description: string
authorId: number
}
Now, when service layer is finished, we can put it together!
Putting together
We need to start from connecting things together, to do so we need to fetch users and create Ability instances for each one:
import { findBy } from './services/users';
import { createAbility } from './services/appAbility';
export default async function main() {
const [admin, author] = await Promise.all([
findBy({ email: 'admin@casl.io' }),
findBy({ email: 'member@casl.io' }),
]);
const adminAbility = createAbility(admin!.permissions);
const authorAbility = createAbility(author!.permissions);
// the rest of the code
}
We also need to create 2 articles, one that is written by admin and another by author:
// imports here
export default async function main() {
// fetch users & create ability instances
const adminArticle = {
title: 'CASl and TypeScript',
description: 'Is very powerful',
authorId: admin!.id
};
const authorArticle = {
title: 'CASl and TypeScript',
description: 'Is very powerful',
authorId: author!.id
};
// the rest of the code
}
And finally let's check our permissions:
adminuser should be able to create article in spite of who wrote itmemberuser should be able to only create articles that were written by him
// other imports here
import * as articles from './services/articles';
export default async function main() {
await articles.create(adminAbility, adminArticle);
console.log('[admin]: created article written by himself')
try {
await articles.create(authorAbility, adminArticle);
} catch (error) {
console.log('[member]: cannot create an article written by admin');
}
await articles.create(adminAbility, authorArticle);
console.log('[admin]: created article written by author');
await articles.create(authorAbility, authorArticle);
console.log('[author]: created article written by himself');
console.log('\nAll articles ');
const rawArticles = await articles.find();
rawArticles.forEach((article) => {
const prefix = article.authorId === admin!.id ? '[admin]' : '[author]';
console.log(`${prefix}: ${article.title}`);
});
}
Let's call main function and see the results:
$ npm start
[admin]: created article written by himself
[author]: cannot create an article written by admin
[admin]: created article written by author
[author]: created article written by himself
All articles
[admin]: CASl and TypeScript
[author]: CASl and TypeScript
[author]: CASl and TypeScript
Everything works as we it should! Now let's allow members to create any article. To do so, we need to update permissions of member role and we will do this using knex:
import db from './db';
db('roles')
.update({
permissions: JSON.stringify([
{ action: ['read', 'create'], subject: 'Article' },
{ action: ['update', 'delete'], subject: 'Article', conditions: { authorId: '${user.id}'} },
])
})
.where('name', 'member')
.then(() => {
console.log('Permissions of "member" role has been updated');
return db.destroy();
})
.catch(console.error);
After updating member's permissions and running main function again, we got this results:
$ npm start
[admin]: created own article
[member]: created article written by admin <---
[admin]: created article written by author
[member]: created article written by himself
All articles
[admin]: CASl and TypeScript
[member]: CASl and TypeScript
[member]: CASl and TypeScript
[admin]: CASl and TypeScript
[admin]: CASl and TypeScript
[member]: CASl and TypeScript
[member]: CASl and TypeScript
As you can see, member is now allowed to create articles created by admins!
Conclusion
That's it! You now have a fully manageable CASL powered permissions logic in your app. We hope this tutorial was helpful and made your development experience with CASL.js even more enjoyable :)
You can find the source code of this solution on Github.
When to avoid
- If you have a predefined set of permissions for every role which very unlikely to change.
- If you start a new project and not sure whether you need to dynamically configure permissions.
Alternative patterns
As an alternative you can implement predefined permissions with CASL . It brings the same benefits but with less overhead.
If you need to implement claim based permissions, check Claim based Authorization recipe.