Skip to content

Instantly share code, notes, and snippets.

@walokra
Last active January 22, 2024 04:34
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save walokra/180292e7c39c8536173a9c5b3a0edb11 to your computer and use it in GitHub Desktop.
Save walokra/180292e7c39c8536173a9c5b3a0edb11 to your computer and use it in GitHub Desktop.
CASL roles with persisted permissions
Table Users {
id uuid [pk, not null, unique]
givenName string
familyName string
email string
phone string
}
Table TenantUsers {
TenantId uuid [ref: > Tenants.id]
UserId uuid [ref: > Users.id]
Roles array [ref: > Roles.id]
}
Table Tenants {
id uuid [pk]
}
Table Roles {
id int [pk, increment]
TenantId uuid [ref: > Tenants.id]
type int
value string
}
Table Permissions {
id int [pk, increment]
action string
subject string
fields array
conditions string
inverted boolean
system boolean
}
Table RolePermissions {
RoleId int [ref: > Roles.id]
PermissionId int [ref: > Permissions.id]
}
Table Groups {
id int [pk, increment]
TenantsId uuid [ref: > Tenants.id]
Roles array [ref: > Roles.id]
}
Table GroupUsers {
id int [pk, increment]
GroupId int [ref: > Groups.id]
UserId uuid [ref: > Users.id]
}
{
"sub": "1234567890",
"role": "user",
"groups": [1, 3],
"permissions": [
{
"action": "read",
"subject": "Users",
"fields": null,
"conditions": {
"id": "${user.tenant}"
}
},
{
"action": "edit",
"subject": "User",
"fields": null,
"conditions": {
"id": "${user.id}",
"TenantId": "${user.tenant}"
}
},
]
}
const { AbilityBuilder, Ability } = require('@casl/ability');
const parseCondition = (template, vars) => {
if (!template) {
return null;
}
JSON.parse(JSON.stringify(template), (_, rawValue) => {
if (rawValue[0] !== '$') {
return rawValue;
}
const name = rawValue.slice(2, -1);
const value = get(vars, name);
if (typeof value === 'undefined') {
throw new ReferenceError(`Variable ${name} is not defined`);
}
return value;
});
return null;
};
const defineAbilitiesFor = (jwtToken) => {
const { can: allow, build } = new AbilityBuilder(Ability);
jwtToken.permissions.forEach((item) => {
const parsedConditions = parseCondition(item.conditions, { jwtToken });
allow(item.action, item.subject, item.fields, parsedConditions);
});
return build();
};
module.exports = { defineAbilitiesFor };
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Group extends Model {
toJSON() {
return {
id: this.id,
TenantId: this.TenantId,
name: this.name,
roles: this.roles,
};
}
}
Group.init(
{
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false,
},
TenantId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Tenant',
key: 'id',
},
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
roles: DataTypes.ARRAY(DataTypes.INTEGER),
},
{
sequelize,
modelName: 'Group',
},
);
return Group;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class GroupUser extends Model {
static associate(models) {
this.Group = this.belongsTo(models.Group);
this.User = this.belongsTo(models.User);
}
toJSON() {
return {
...this.Group?.toJSON(),
...this.User?.toJSON(),
id: this.id,
};
}
}
GroupUser.init(
{
id: {
autoIncrement: true,
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false,
},
GroupId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Group',
key: 'id',
},
},
UserId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'User',
key: 'id',
},
},
},
{
sequelize,
modelName: 'GroupUser',
},
);
return GroupUser;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Permission extends Model {
static associate() {}
toJSON() {
return {
id: this.id,
action: this.action,
subject: this.subject,
fields: this.fields,
conditions: this.conditions,
inverted: this.inverted,
system: this.system,
};
}
}
Permission.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false,
},
action: DataTypes.STRING,
subject: DataTypes.STRING,
fields: DataTypes.ARRAY(DataTypes.TEXT),
conditions: DataTypes.JSON,
inverted: DataTypes.BOOLEAN,
system: DataTypes.BOOLEAN,
},
{
sequelize,
modelName: 'Permission',
},
);
return Permission;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Role extends Model {
static associate() {}
static get Type() {
return {
Admin: 1,
};
}
toJSON() {
return {
type: this.type,
value: this.value,
};
}
}
Role.init(
{
type: DataTypes.INTEGER,
value: DataTypes.STRING,
},
{
sequelize,
modelName: 'Role',
},
);
return Role;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class RolePermission extends Model {
static associate(models) {
this.Permission = this.belongsTo(models.Permission);
this.Role = this.belongsTo(models.Role);
}
toJSON() {
return {
...this.Permission?.toJSON(),
role: this.Role?.value,
};
}
}
RolePermission.init(
{
RoleId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Role',
key: 'id',
},
},
PermissionId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Permission',
key: 'id',
},
},
},
{
sequelize,
modelName: 'RolePermission',
timestamps: false,
},
);
return RolePermission;
};
const express = require('express');
const { param, validationResult } = require('express-validator');
const { subject } = require('@casl/ability');
const { permittedFieldsOf } = require('@casl/ability/extra');
const pick = require('lodash/pick');
const { Forbidden } = require('http-errors');
const router = express.Router();
const ForbiddenOperationError = {
from: (abilities) => ({
throwUnlessCan: (...args) => {
if (!abilities.can(...args)) {
throw Forbidden();
}
},
}),
};
router.get('/users', async (req, res, next) => {
const { user, abilities } = req;
try {
ForbiddenOperationError.from(abilities).throwUnlessCan('read', 'Users');
const users = await UserService.getAll(user.tenant);
return res.send({ entities: users });
} catch (e) {
return next(e);
}
});
router.put('/users/:userId', param('userId').isUUID(4), async (req, res, next) => {
const {
user: { tenant },
abilities,
params: { userId },
body: userToBeUpdated,
} = req;
try {
ForbiddenOperationError.from(abilities).throwUnlessCan(
'edit',
subject('User', {
...userToBeUpdated,
id: userId,
TenantId: tenant,
}),
);
const permittedFieldsToBeUpdated = permittedFieldsOf(abilities, 'edit', 'User', {
fieldsFrom: (rule) => rule.fields,
});
const userFieldsToBeUpdated = pick(userToBeUpdated, permittedFieldsToBeUpdated);
const user = await UserService.get(userId, tenant);
ForbiddenOperationError.from(abilities).throwUnlessCan('edit', subject('User', user));
const updatedUser = await UserService.update(userId, userFieldsToBeUpdated, tenant);
return res.send(updatedUser);
} catch (e) {
return next(e);
}
});
module.exports = router;
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const mapPermissionsFromRole = (rolePermissions) =>
rolePermissions.map((p) => ({
permissionId: p.Permission.id,
action: p.Permission.action,
subject: p.Permission.subject,
fields: p.Permission.fields,
conditions: p.Permission.conditions,
}));
const getPermissionsAndGroups = async (claims = {}) => {
let permissions = [];
let groups = [];
if (!claims || !claims.role || !claims.sub || !claims.tenant) {
return { permissions, groups };
}
const rolePermissions = await RolePermissionsService.getByRole(claims.role, claims.tenant);
const permissionsFromRole = mapPermissionsFromRole(rolePermissions);
// claims.sub - permissions from groups user is in
const userGroups = await GroupUserService.getUserGroups(claims.sub);
const roleIds = userGroups.map((g) => g.Group.roles).flat();
const rolePermissionsFromGroups = await Promise.all(
roleIds.map(async (roleId) => RolePermissionsService.getByRoleId(roleId)),
);
const permissionsFromGroups = mapPermissionsFromRole(rolePermissionsFromGroups.flat());
// Get and merge group id's for JWT
const groupIds = userGroups.map((g) => g.Group.id);
groups = [...new Set(groupIds)];
// Merge permissions from different roles to be unique for JWT
const allPermissions = permissionsFromRole.concat(permissionsFromGroups);
permissions = [...new Map(allPermissions.map((item) => [item.permissionId, item])).values()];
return { permissions, groups };
};
const generateKeyPair = () =>
crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const { publicKey, privateKey } = generateKeyPair();
const generateTestTokenWithPermissions = async (claims = {}, secret = privateKey, options) => {
const { permissions, groups } = await getPermissionsAndGroups(claims);
return jwt.sign(
{
tenant: 'test',
...claims,
permissions,
groups,
},
secret,
{
algorithm: 'RS256',
keyid: 'test-key-id',
...options,
},
);
};
module.exports = {
generateKeyPair,
generateTestTokenWithPermissions,
publicKey,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment