// Admin RBAC route registration and permission management. const { DEFAULT_ROLE } = require('#src/rbac'); const { buildAuditChanges } = require('#src/data/audit-log'); module.exports = function registerRbacRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const getAuditUserId = deps.getAuditUserId; const recordRequestAuditEvent = deps.recordRequestAuditEvent; const rbacData = deps.rbacData; const permissions = Array.isArray(deps.permissions) ? deps.permissions : []; const readArrayField = deps.readArrayField; const normalizePermissionKeys = deps.normalizePermissionKeys; const { buildPagination } = require('../../lib/pagination'); const requirePermission = deps.requirePermission; const { buildDuplicateRoleName, buildDuplicateRole } = require('../settings/rbac/duplicate'); const ROLE_NAME_MAX_LENGTH = 255; const ROLE_DESCRIPTION_MAX_LENGTH = 255; const ROLE_KEY_MAX_LENGTH = 100; const LIST_PAGE_SIZE = 25; function toSortIndex(value) { const number = Number(value); return Number.isFinite(number) ? number : 999; } function slugifyRoleKey(name) { const value = String(name || '').trim().toLowerCase(); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH); return slug || 'role'; } function normalizeSelectedIds(values) { return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) { return Number(value); }).filter(function (value) { return Number.isInteger(value) && value > 0; }))); } function getActionLabel(actionKey) { const normalizedActionKey = String(actionKey || '').trim().toLowerCase(); if (normalizedActionKey === 'edit') { return 'Update'; } if (normalizedActionKey === 'allow') { return 'Allow'; } if (!normalizedActionKey) { return 'Read'; } return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1); } async function createUniqueRoleKey(baseName) { const baseKey = slugifyRoleKey(baseName); let candidate = baseKey; let suffix = 2; while (true) { const [rows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [candidate]); if (!rows.length) { return candidate; } candidate = `${baseKey}-${suffix}`; suffix += 1; } } function mapPermissionsForView(permissionRows, selectedPermissionKeys) { const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys)); const permissionDefinitions = new Map(permissions.map(function (permission) { return [String(permission.key || '').trim(), permission]; })); return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) { const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null; return Object.assign({}, permission, { resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), resourceName: definition ? definition.resourceName : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name, sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999, sectionOrder: definition ? definition.sectionOrder : 999, resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999, resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999, permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999, permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999, actionKey: definition ? definition.actionKey : 'read', actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), isSelected: selectedKeys.has(String(permission.permission_key || '').trim()) }); }); } function mapUsersForView(userRows, selectedUserIds) { const selectedIds = new Set(normalizeSelectedIds(selectedUserIds)); return (Array.isArray(userRows) ? userRows : []).map(function (user) { return Object.assign({}, user, { isSelected: selectedIds.has(Number(user.id)) }); }); } function buildPermissionGroups(permissionRows) { const groups = []; const groupIndex = new Map(); (Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) { const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); if (!groupIndex.has(sectionKey)) { const group = { id: sectionKey || 'permissions', title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), categoryName: String(permission.categoryName || '').trim(), sectionIndex: toSortIndex(permission.sectionIndex), sectionOrder: Number(permission.sectionOrder) || 999, resourceIndex: toSortIndex(permission.resourceIndex), resourceOrder: Number(permission.resourceOrder) || 999, permissions: [] }; groupIndex.set(sectionKey, group); groups.push(group); } groupIndex.get(sectionKey).permissions.push(permission); }); groups.forEach(function (group) { group.permissions.sort(function (left, right) { const leftIndex = toSortIndex(left.permissionIndex); const rightIndex = toSortIndex(right.permissionIndex); if (leftIndex !== rightIndex) { return leftIndex - rightIndex; } const leftOrder = toSortIndex(left.permissionOrder); const rightOrder = toSortIndex(right.permissionOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left.name || '').localeCompare(String(right.name || '')); }); }); groups.sort(function (left, right) { const leftIndex = toSortIndex(left.sectionIndex); const rightIndex = toSortIndex(right.sectionIndex); if (leftIndex !== rightIndex) { return leftIndex - rightIndex; } const leftOrder = toSortIndex(left.sectionOrder); const rightOrder = toSortIndex(right.sectionOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left.title || '').localeCompare(String(right.title || '')); }); return groups; } async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) { const permissionRows = await rbacData.fetchPermissions(pool); const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, { excludeUserId: currentUserId }); return { formValues: { name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'), description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description') }, permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), users: mapUsersForView(data.users, selectedUserIds), pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages') }; } async function loadRoleViewModel(roleId) { const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return null; } const permissionRows = await rbacData.fetchPermissions(pool); const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id); const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id); return { role: Object.assign({}, role, { permissionKeys: selectedPermissionKeys, permissionCount: Number(role.permission_count) || 0, userCount: Number(role.user_count) || 0 }), permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), selectedUserIds: selectedUserIds }; } app.get('/settings/roles', requirePermission('rbac.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderRbacPage({ roles: data.roles || [], pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/settings/roles/new', requirePermission('rbac.create'), function (req, res, next) { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; buildRoleCreateViewModel({ name: String(req.query.name || '').trim(), description: String(req.query.description || '').trim() }, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) { res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary')); }).catch(function (error) { next(error); }); }); app.get('/settings/roles/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId); const viewModel = await buildRoleCreateViewModel({ name: buildDuplicateRoleName(role.name), description: buildDuplicateRole(role).description }, permissionKeys, [], page, search, sort, direction, currentUserId); res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary')); } catch (error) { next(error); } }); app.post('/settings/roles', requirePermission('rbac.create'), async function (req, res, next) { try { const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'); const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description'); const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present') ? readArrayField(req.body, ['user_ids[]', 'user_ids']) : []; const normalizedUserIds = normalizeSelectedIds(selectedUserIds); const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]']) ? req.body['permission_keys[]'] : req.body.permission_keys ? [].concat(req.body.permission_keys) : []); const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId); if (!name) { return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } if (await common.fetchDuplicateName(pool, 'a_roles', name)) { return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } if (selectedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } const availableUsers = await rbacData.fetchUsersWithRoles(pool); const availableUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); })); if (normalizedUserIds.some(function (userId) { return !availableUserIds.has(userId); })) { return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } const roleKey = await createUniqueRoleKey(name); const actorId = getAuditUserId(req); const connection = await pool.getConnection(); let insertedRoleId = null; try { await connection.beginTransaction(); const [result] = await connection.query( 'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [roleKey, name, description || null, actorId, actorId] ); insertedRoleId = Number(result.insertId); if (selectedPermissionKeys.length) { await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys); } await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.created', actorUserId: actorId, targetType: 'role', targetId: insertedRoleId, targetLabel: name, details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds } }); } res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.')); } catch (error) { next(error); } }); app.get('/settings/roles/:id/edit', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const viewModel = await loadRoleViewModel(roleId); if (!viewModel) { return res.status(404).send('Role not found.'); } const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, { excludeUserId: currentUserId }); const users = mapUsersForView(data.users, viewModel.selectedUserIds); viewModel.role.inUse = String(viewModel.role.role_key || '') === DEFAULT_ROLE.key || String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0; res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages'))); } catch (error) { next(error); } }); app.post('/settings/roles/:id', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'); const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description'); const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present'); const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present'); const selectedPermissionKeys = shouldSyncPermissions ? readArrayField(req.body, ['permission_keys[]', 'permission_keys']) : []; const selectedUserIds = shouldSyncUsers ? readArrayField(req.body, ['user_ids[]', 'user_ids']) : []; const visibleUserIds = shouldSyncUsers ? normalizeSelectedIds(readArrayField(req.body, ['user_ids_present[]', 'user_ids_present'])) : []; const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : []; let normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : []; if (!name) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.')); } if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.')); } const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } const existingRolePermissionKeys = shouldSyncPermissions ? await rbacData.fetchRolePermissionKeys(pool, roleId) : []; let existingRoleUserIds = []; let availableUsers = []; if (shouldSyncUsers) { availableUsers = await rbacData.fetchUsersWithRoles(pool); existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId); const visibleUserIdSet = new Set(visibleUserIds); normalizedUserIds = existingRoleUserIds.filter(function (userId) { return !visibleUserIdSet.has(userId); }).concat(normalizedUserIds); } const validUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); })); if (shouldSyncUsers && normalizedUserIds.some(function (userId) { return !validUserIds.has(userId); })) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.')); } const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.query( 'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?', [name, description || null, getAuditUserId(req), roleId] ); if (shouldSyncPermissions) { await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys); } if (shouldSyncUsers) { await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } if (typeof recordRequestAuditEvent === 'function') { const changes = buildAuditChanges({ name: role.name, description: role.description, permissionKeys: existingRolePermissionKeys, userIds: existingRoleUserIds }, { name: name, description: description || null, permissionKeys: normalizedPermissionKeys, userIds: normalizedUserIds }); await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.updated', actorUserId: req.currentUser.id, targetType: 'role', targetId: roleId, targetLabel: name, details: { changes: changes } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.')); } catch (error) { next(error); } }); app.post('/settings/roles/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]']) ? req.body['permission_keys[]'] : req.body.permission_keys ? [].concat(req.body.permission_keys) : []; const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys); if (normalizedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId); const connection = await pool.getConnection(); try { await connection.beginTransaction(); await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.permissions_updated', actorUserId: req.currentUser.id, targetType: 'role', targetId: roleId, targetLabel: role.name, details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.')); } catch (error) { next(error); } }); app.post('/settings/roles/:id/users', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const selectedUserIds = Array.isArray(req.body['user_ids[]']) ? req.body['user_ids[]'] : req.body.user_ids ? [].concat(req.body.user_ids) : []; const normalizedUserIds = normalizeSelectedIds(selectedUserIds); const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId); const availableUsers = await rbacData.fetchUsersWithRoles(pool); const validUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); })); if (normalizedUserIds.some(function (userId) { return !validUserIds.has(userId); })) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.')); } await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.users_updated', actorUserId: req.currentUser.id, targetType: 'role', targetId: roleId, targetLabel: role.name, details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.')); } catch (error) { next(error); } }); app.post('/settings/roles/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } if (String(role.role_key || '') === DEFAULT_ROLE.key || String(role.role_key || '') === 'administrators') { return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Super Admin role cannot be deleted.')); } if (Number(role.user_count) > 0) { return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.')); } await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.deleted', actorUserId: req.currentUser.id, targetType: 'role', targetId: roleId, targetLabel: role.name }); } res.redirect('/settings/roles?message=' + encodeURIComponent('Role deleted.')); } catch (error) { next(error); } }); };