Implement RBAC roles system
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { hashPassword } = require('./auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
@@ -38,6 +39,229 @@ async function pruneStaleOnboardingDevices(pool) {
|
||||
);
|
||||
}
|
||||
|
||||
async function getTableColumnNames(pool, tableName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return new Set((rows || []).map(function (row) {
|
||||
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
|
||||
}).filter(Boolean));
|
||||
}
|
||||
|
||||
function getPermissionKey(row) {
|
||||
return String((row && (row.permission_key || row.perm_key)) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getLegacyPermissionTargets(permissionKey) {
|
||||
const normalizedKey = String(permissionKey || '').trim().toLowerCase();
|
||||
const parts = normalizedKey.split('.');
|
||||
if (parts.length !== 2) {
|
||||
return [normalizedKey].filter(Boolean);
|
||||
}
|
||||
|
||||
const sectionKey = parts[0];
|
||||
const actionKey = parts[1];
|
||||
if (actionKey === 'view') {
|
||||
return [`${sectionKey}.read`];
|
||||
}
|
||||
if (actionKey === 'manage') {
|
||||
return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.edit`, `${sectionKey}.delete`];
|
||||
}
|
||||
|
||||
return [normalizedKey].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPermissionSeedColumns(columnNames) {
|
||||
const columns = [];
|
||||
if (columnNames.has('permission_key')) {
|
||||
columns.push('permission_key');
|
||||
}
|
||||
if (columnNames.has('perm_key')) {
|
||||
columns.push('perm_key');
|
||||
}
|
||||
if (columnNames.has('name')) {
|
||||
columns.push('name');
|
||||
}
|
||||
if (columnNames.has('section_name')) {
|
||||
columns.push('section_name');
|
||||
}
|
||||
if (columnNames.has('description')) {
|
||||
columns.push('description');
|
||||
}
|
||||
if (columnNames.has('created_by')) {
|
||||
columns.push('created_by');
|
||||
}
|
||||
if (columnNames.has('modified_by')) {
|
||||
columns.push('modified_by');
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
async function backfillLegacyRbacSchema(pool) {
|
||||
const permissionColumnNames = await getTableColumnNames(pool, 'permissions');
|
||||
const hasPermissionKeyColumn = permissionColumnNames.has('permission_key');
|
||||
const hasLegacyPermissionKeyColumn = permissionColumnNames.has('perm_key');
|
||||
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key, perm_key, name, section_name FROM permissions ORDER BY id ASC');
|
||||
const [rolePermissionRows] = await pool.query(
|
||||
`SELECT rp.role_id, p.permission_key, p.perm_key
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id`
|
||||
);
|
||||
|
||||
const rolePermissionTargets = new Map();
|
||||
const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) {
|
||||
return permission.key;
|
||||
}));
|
||||
const legacyPermissionRowIds = [];
|
||||
|
||||
function addRoleTarget(roleId, permissionKey) {
|
||||
const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase();
|
||||
if (!normalizedPermissionKey) {
|
||||
return;
|
||||
}
|
||||
if (!rolePermissionTargets.has(roleId)) {
|
||||
rolePermissionTargets.set(roleId, new Set());
|
||||
}
|
||||
rolePermissionTargets.get(roleId).add(normalizedPermissionKey);
|
||||
}
|
||||
|
||||
for (const row of rolePermissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
const targetKeys = getLegacyPermissionTargets(currentKey);
|
||||
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) {
|
||||
for (const targetKey of targetKeys) {
|
||||
addRoleTarget(Number(row.role_id), targetKey);
|
||||
}
|
||||
} else {
|
||||
addRoleTarget(Number(row.role_id), currentKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of permissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) {
|
||||
legacyPermissionRowIds.push(Number(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) {
|
||||
return result[0] || [];
|
||||
});
|
||||
const defaultRoleRow = roleRows.find(function (row) {
|
||||
return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key;
|
||||
}) || null;
|
||||
if (defaultRoleRow) {
|
||||
if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) {
|
||||
rolePermissionTargets.set(Number(defaultRoleRow.id), new Set());
|
||||
}
|
||||
const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id));
|
||||
for (const permission of PERMISSIONS) {
|
||||
defaultPermissions.add(permission.key);
|
||||
}
|
||||
}
|
||||
|
||||
const seedColumns = buildPermissionSeedColumns(permissionColumnNames);
|
||||
if (!seedColumns.length) {
|
||||
throw new Error('permissions table is missing required columns.');
|
||||
}
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
const seedValues = [];
|
||||
if (permissionColumnNames.has('permission_key')) {
|
||||
seedValues.push(permission.key);
|
||||
}
|
||||
if (permissionColumnNames.has('perm_key')) {
|
||||
seedValues.push(permission.key);
|
||||
}
|
||||
if (permissionColumnNames.has('name')) {
|
||||
seedValues.push(permission.name);
|
||||
}
|
||||
if (permissionColumnNames.has('section_name')) {
|
||||
seedValues.push(permission.sectionName);
|
||||
}
|
||||
if (permissionColumnNames.has('description')) {
|
||||
seedValues.push(permission.description || null);
|
||||
}
|
||||
if (permissionColumnNames.has('created_by')) {
|
||||
seedValues.push(null);
|
||||
}
|
||||
if (permissionColumnNames.has('modified_by')) {
|
||||
seedValues.push(null);
|
||||
}
|
||||
|
||||
const updateAssignments = [];
|
||||
if (permissionColumnNames.has('name')) {
|
||||
updateAssignments.push('name = VALUES(name)');
|
||||
}
|
||||
if (permissionColumnNames.has('section_name')) {
|
||||
updateAssignments.push('section_name = VALUES(section_name)');
|
||||
}
|
||||
if (permissionColumnNames.has('description')) {
|
||||
updateAssignments.push('description = VALUES(description)');
|
||||
}
|
||||
if (permissionColumnNames.has('perm_key')) {
|
||||
updateAssignments.push('perm_key = VALUES(perm_key)');
|
||||
}
|
||||
if (permissionColumnNames.has('permission_key')) {
|
||||
updateAssignments.push('permission_key = VALUES(permission_key)');
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO permissions (${seedColumns.join(', ')})
|
||||
VALUES (${seedColumns.map(function () { return '?'; }).join(', ')})
|
||||
ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`,
|
||||
seedValues
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyPermissionRowIds.length) {
|
||||
await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]);
|
||||
}
|
||||
|
||||
const [currentPermissionRows] = await pool.query('SELECT id, permission_key, perm_key FROM permissions');
|
||||
const permissionIdByKey = new Map();
|
||||
for (const row of currentPermissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
if (currentKey) {
|
||||
permissionIdByKey.set(currentKey, Number(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM role_permissions');
|
||||
for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) {
|
||||
const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values()));
|
||||
for (const permissionKey of expandedPermissionKeys) {
|
||||
const permissionId = permissionIdByKey.get(permissionKey);
|
||||
if (!permissionId) {
|
||||
continue;
|
||||
}
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[Number(roleId), permissionId, null, null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC');
|
||||
for (const row of roleRowsAfter || []) {
|
||||
const currentKey = String(row.role_key || '').trim();
|
||||
const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase();
|
||||
const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`;
|
||||
if (!currentKey || currentKey !== expectedKey) {
|
||||
await pool.query(
|
||||
'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?',
|
||||
[expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||
@@ -239,6 +463,73 @@ async function ensureSchema(pool) {
|
||||
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'roles', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'roles', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
permission_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
perm_key VARCHAR(100) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
section_name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'permissions', 'perm_key', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'permissions', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'role_permissions', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'role_permissions', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'user_roles', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'user_roles', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
session_hash CHAR(64) PRIMARY KEY,
|
||||
@@ -265,6 +556,27 @@ async function ensureSchema(pool) {
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||
);
|
||||
|
||||
await backfillLegacyRbacSchema(pool);
|
||||
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
|
||||
SELECT id, ?, NULL, NULL FROM users`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user