-
-
Save walokra/180292e7c39c8536173a9c5b3a0edb11 to your computer and use it in GitHub Desktop.
CASL roles with persisted permissions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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}" | |
} | |
}, | |
] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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