diff --git a/src/__tests__/permissions.test.js b/src/__tests__/permissions.test.js new file mode 100644 index 0000000..2b3b646 --- /dev/null +++ b/src/__tests__/permissions.test.js @@ -0,0 +1,12 @@ +/* eslint-disable no-undef */ +const permissions = require('../permissions'); + +describe('Permissions service', () => { + it('should build ancestry from resource RN', () => { + expect(permissions.buildAncestry('rn::class:1::group:2')).toEqual([ + 'rn::class:1::group:2', + 'rn::class:1', + 'rn' + ]); + }); +}); diff --git a/src/index.js b/src/index.js index fc638d1..e8f1eaf 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ const craftBase = require('./libs/craft'); const { formatGraphQLError } = require('./libs/error'); const { defaultOptions } = require('./options'); const { copyMissing } = require('./utils'); +const permissions = require('./permissions'); const init = (options) => { @@ -39,6 +40,8 @@ const init = (options) => { }; }; +init.permissions = permissions; + module.exports = init; /* diff --git a/src/permissions/index.js b/src/permissions/index.js new file mode 100644 index 0000000..8fb54b1 --- /dev/null +++ b/src/permissions/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const migrations = require('./migrations'); +const service = require('./service'); + +module.exports = { + migrations, + ...service +}; diff --git a/src/permissions/migrations/001-create-roles.js b/src/permissions/migrations/001-create-roles.js new file mode 100644 index 0000000..660af8e --- /dev/null +++ b/src/permissions/migrations/001-create-roles.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = { + async up (qi, Sequelize) { + await qi.createTable('Roles', { + id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, allowNull: false }, + name: { type: Sequelize.STRING, allowNull: false, unique: true }, + rank: { type: Sequelize.INTEGER, allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') } + }); + await qi.addIndex('Roles', ['rank']); + }, + async down (qi) { + await qi.dropTable('Roles'); + } +}; diff --git a/src/permissions/migrations/002-create-users.js b/src/permissions/migrations/002-create-users.js new file mode 100644 index 0000000..a73dd2a --- /dev/null +++ b/src/permissions/migrations/002-create-users.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = { + async up (qi, Sequelize) { + await qi.createTable('Users', { + id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, allowNull: false }, + username: { type: Sequelize.STRING, allowNull: false, unique: true }, + roleId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Roles', key: 'id' }, + onDelete: 'RESTRICT' + }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') } + }); + await qi.addIndex('Users', ['roleId']); + }, + async down (qi) { + await qi.dropTable('Users'); + } +}; diff --git a/src/permissions/migrations/003-create-resource-index.js b/src/permissions/migrations/003-create-resource-index.js new file mode 100644 index 0000000..4170183 --- /dev/null +++ b/src/permissions/migrations/003-create-resource-index.js @@ -0,0 +1,23 @@ +'use strict'; + +module.exports = { + async up (qi, Sequelize) { + await qi.createTable('ResourceIndices', { + resourceRn: { type: Sequelize.STRING, primaryKey: true, allowNull: false }, + model: { type: Sequelize.STRING, allowNull: false }, + ownerUserId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Users', key: 'id' }, + onDelete: 'CASCADE' + }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') } + }); + await qi.addIndex('ResourceIndices', ['ownerUserId']); + await qi.addIndex('ResourceIndices', ['model']); + }, + async down (qi) { + await qi.dropTable('ResourceIndices'); + } +}; diff --git a/src/permissions/migrations/004-create-permission-assignments.js b/src/permissions/migrations/004-create-permission-assignments.js new file mode 100644 index 0000000..484d06b --- /dev/null +++ b/src/permissions/migrations/004-create-permission-assignments.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + async up (qi, Sequelize) { + await qi.createTable('PermissionAssignments', { + id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, allowNull: false }, + roleId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Roles', key: 'id' }, + onDelete: 'CASCADE' + }, + resourceRn: { type: Sequelize.STRING, allowNull: false }, + permission: { type: Sequelize.STRING, allowNull: false }, + allow: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') } + }); + await qi.addIndex('PermissionAssignments', ['roleId']); + await qi.addIndex('PermissionAssignments', ['resourceRn']); + await qi.addIndex('PermissionAssignments', ['permission']); + await qi.addIndex('PermissionAssignments', ['roleId', 'resourceRn', 'permission']); + }, + async down (qi) { + await qi.dropTable('PermissionAssignments'); + } +}; diff --git a/src/permissions/migrations/005-create-field-policies.js b/src/permissions/migrations/005-create-field-policies.js new file mode 100644 index 0000000..7807d66 --- /dev/null +++ b/src/permissions/migrations/005-create-field-policies.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = { + async up (qi, Sequelize) { + await qi.createTable('FieldPolicies', { + id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, allowNull: false }, + roleId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Roles', key: 'id' }, + onDelete: 'CASCADE' + }, + model: { type: Sequelize.STRING, allowNull: false }, + resourceRn: { type: Sequelize.STRING, allowNull: false }, + field: { type: Sequelize.STRING, allowNull: false }, + action: { type: Sequelize.ENUM('read', 'write'), allowNull: false }, + allow: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn('now') } + }); + await qi.addIndex('FieldPolicies', ['roleId']); + await qi.addIndex('FieldPolicies', ['model']); + await qi.addIndex('FieldPolicies', ['resourceRn']); + await qi.addIndex('FieldPolicies', ['field']); + await qi.addIndex('FieldPolicies', ['action']); + await qi.addIndex('FieldPolicies', ['roleId', 'model', 'resourceRn', 'action']); + }, + async down (qi) { + await qi.dropTable('FieldPolicies'); + } +}; diff --git a/src/permissions/migrations/index.js b/src/permissions/migrations/index.js new file mode 100644 index 0000000..6d2e91d --- /dev/null +++ b/src/permissions/migrations/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = [ + require('./001-create-roles'), + require('./002-create-users'), + require('./003-create-resource-index'), + require('./004-create-permission-assignments'), + require('./005-create-field-policies') +]; diff --git a/src/permissions/service.js b/src/permissions/service.js new file mode 100644 index 0000000..8bab119 --- /dev/null +++ b/src/permissions/service.js @@ -0,0 +1,121 @@ +'use strict'; + +const { Sequelize } = require('sequelize'); + +function buildAncestry(resourceRn) { + const parts = resourceRn.split('::'); + const out = []; + + for (let i = parts.length; i > 0; i--) { + out.push(parts.slice(0, i).join('::')); + } + + return out; +} + +async function getUserWithRole(models, userId) { + const { User, Role } = models; + + const user = await User.findByPk(userId, { include: [{ model: Role }] }); + + return user; +} + +async function getOwnerRole(models, resourceRn) { + const { ResourceIndex, Role } = models; + + const res = await ResourceIndex.findByPk(resourceRn, { + include: [{ association: ResourceIndex.associations.owner, include: [Role] }] + }); + + return res && res.owner ? res.owner.Role : null; +} + +async function hasPermission(models, { userId, resourceRn, permission, enableOwnerFallback = true }) { + const { PermissionAssignment } = models; + + const user = await getUserWithRole(models, userId); + + if (!user) throw new Error('User not found'); + + const actingRole = user.Role; + const ancestry = buildAncestry(resourceRn); + + const rows = await PermissionAssignment.findAll({ + where: { roleId: actingRole.id, resourceRn: ancestry, permission }, + order: [[Sequelize.literal('LENGTH("resourceRn")'), 'DESC']] + }); + + if (rows.length) { + return rows[0].allow === true; + } + + if (enableOwnerFallback) { + const ownerRole = await getOwnerRole(models, resourceRn); + + if (ownerRole && actingRole.rank > ownerRole.rank) return true; + } + + return false; +} + +async function getAllowedFields(models, { userId, resourceRn, modelName, action, allFields = [] }) { + const { FieldPolicy } = models; + + const user = await getUserWithRole(models, userId); + + if (!user) throw new Error('User not found'); + + const roleId = user.roleId; + const ancestry = buildAncestry(resourceRn); + + const rows = await FieldPolicy.findAll({ + where: { roleId, model: modelName, action, resourceRn: ancestry } + }); + + rows.sort((a, b) => { + const rnDelta = b.resourceRn.length - a.resourceRn.length; + + if (rnDelta !== 0) return rnDelta; + const aWild = a.field === '*'; + const bWild = b.field === '*'; + + return aWild === bWild ? 0 : aWild ? 1 : -1; + }); + + const allowed = new Set(); + + for (const p of rows) { + if (p.field === '*') { + if (p.allow) { + (allFields.length ? allFields : []).forEach((f) => allowed.add(f)); + } else { + allowed.clear(); + } + } else if (p.allow) { + allowed.add(p.field); + } else { + allowed.delete(p.field); + } + } + + return Array.from(allowed); +} + +function projectAttributes(attributes, allowedFields) { + return attributes.filter((a) => allowedFields.includes(a)); +} + +function sanitizePayload(payload, allowedFields) { + return Object.fromEntries( + Object.entries(payload).filter(([k]) => allowedFields.includes(k)) + ); +} + +module.exports = { + buildAncestry, + hasPermission, + getAllowedFields, + projectAttributes, + sanitizePayload +};