Implement RBAC roles system

This commit is contained in:
2026-07-21 21:05:02 +01:00
parent 7973ee0ea4
commit e051958bea
35 changed files with 2338 additions and 2257 deletions
+312
View File
@@ -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 = {