From 806a32d30616646618b228f5a63a2eff7250496f Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Thu, 20 Aug 2026 12:28:04 +0100 Subject: [PATCH] Add permission settings management functionality This commit introduces a new feature for managing permission settings within the application. It includes the creation of a new `permissions.js` file for handling permission logic, a corresponding Mongoose schema for permission settings, and route handlers for CRUD operations on permission settings. Additionally, tests have been added to ensure the functionality of the new permission settings service. This enhancement improves the application's capability to manage user permissions effectively. --- src/database/__tests__/permissions.test.js | 29 +++ src/database/permissions.js | 38 +++ .../management/permissionsettings.schema.js | 59 +++++ .../schemas/management/user.schema.js | 43 ++++ src/database/schemas/models.js | 8 + src/index.js | 2 + src/routes/index.js | 2 + src/routes/management/permissionsettings.js | 99 ++++++++ src/routes/management/users.js | 1 + .../__tests__/permissionsettings.test.js | 122 +++++++++ src/services/management/permissionsettings.js | 231 ++++++++++++++++++ src/services/management/users.js | 10 +- 12 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 src/database/__tests__/permissions.test.js create mode 100644 src/database/permissions.js create mode 100644 src/database/schemas/management/permissionsettings.schema.js create mode 100644 src/routes/management/permissionsettings.js create mode 100644 src/services/management/__tests__/permissionsettings.test.js create mode 100644 src/services/management/permissionsettings.js diff --git a/src/database/__tests__/permissions.test.js b/src/database/__tests__/permissions.test.js new file mode 100644 index 0000000..75b6a57 --- /dev/null +++ b/src/database/__tests__/permissions.test.js @@ -0,0 +1,29 @@ +import { applyPermissionSettingsList } from '../permissions.js'; + +describe('applyPermissionSettingsList', () => { + it('applies permission settings in order and inherits intermediate values', () => { + const result = applyPermissionSettingsList([ + { + permissions: { + user: { info: true, edit: false, delete: true }, + printer: { info: true }, + }, + }, + { + permissions: { + user: { edit: true, delete: null }, + printer: { info: null, delete: false }, + }, + }, + ]); + + expect(result).toEqual({ + user: { info: true, edit: true, delete: true }, + printer: { info: true, delete: false }, + }); + }); + + it('returns an empty object when nothing is explicitly set', () => { + expect(applyPermissionSettingsList([{ permissions: { user: { info: null } } }])).toEqual({}); + }); +}); diff --git a/src/database/permissions.js b/src/database/permissions.js new file mode 100644 index 0000000..73d5b4a --- /dev/null +++ b/src/database/permissions.js @@ -0,0 +1,38 @@ +export const applyPermissionSettingsList = (settingsList = []) => { + const permissions = {}; + + for (const setting of settingsList) { + const matrix = setting?.permissions; + if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix)) { + continue; + } + + for (const [modelName, actions] of Object.entries(matrix)) { + if (!actions || typeof actions !== 'object' || Array.isArray(actions)) { + continue; + } + if (!permissions[modelName]) { + permissions[modelName] = {}; + } + for (const [actionName, value] of Object.entries(actions)) { + if (value === true || value === false) { + permissions[modelName][actionName] = value; + } + } + if (Object.keys(permissions[modelName]).length === 0) { + delete permissions[modelName]; + } + } + } + + return permissions; +}; + +export const getPermissionSettingsId = (value) => { + if (!value) return null; + if (typeof value === 'string') return value; + if (value._id) { + return value._id._id || value._id; + } + return value; +}; diff --git a/src/database/schemas/management/permissionsettings.schema.js b/src/database/schemas/management/permissionsettings.schema.js new file mode 100644 index 0000000..688c38f --- /dev/null +++ b/src/database/schemas/management/permissionsettings.schema.js @@ -0,0 +1,59 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { getPermissionSettingsId } from '../../permissions.js'; + +const { Schema } = mongoose; + +const permissionSettingsSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, + }, + { timestamps: true } +); + +permissionSettingsSchema.index({ name: 'text' }); + +permissionSettingsSchema.virtual('id').get(function () { + return this._id; +}); + +permissionSettingsSchema.set('toJSON', { virtuals: true }); + +permissionSettingsSchema.statics.recalculate = async function (permissionSettings, user) { + const permissionSettingsId = getPermissionSettingsId(permissionSettings); + if (!permissionSettingsId) { + return; + } + + const userModel = mongoose.model('user'); + const users = await userModel + .find({ permissionSettings: permissionSettingsId }) + .lean(); + + const stillExists = await this.exists({ _id: permissionSettingsId }); + if (!stillExists) { + await userModel.updateMany( + { permissionSettings: permissionSettingsId }, + { $pull: { permissionSettings: permissionSettingsId } } + ); + } + + for (const assignedUser of users) { + const nextUser = stillExists + ? assignedUser + : { + ...assignedUser, + permissionSettings: (assignedUser.permissionSettings || []).filter( + (item) => String(getPermissionSettingsId(item)) !== String(permissionSettingsId) + ), + }; + await userModel.recalculate(nextUser, user); + } +}; + +export const permissionSettingsModel = mongoose.model( + 'permissionSettings', + permissionSettingsSchema +); diff --git a/src/database/schemas/management/user.schema.js b/src/database/schemas/management/user.schema.js index 283fecb..1da0e62 100644 --- a/src/database/schemas/management/user.schema.js +++ b/src/database/schemas/management/user.schema.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { applyPermissionSettingsList, getPermissionSettingsId } from '../../permissions.js'; + +const { Schema } = mongoose; const userSchema = new mongoose.Schema( { @@ -11,6 +14,10 @@ const userSchema = new mongoose.Schema( email: { required: true, type: String }, profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false }, appPasswordHash: { type: String, required: false, select: false }, + permissionSettings: [ + { type: Schema.Types.ObjectId, ref: 'permissionSettings', required: false }, + ], + permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, }, { timestamps: true } ); @@ -23,4 +30,40 @@ userSchema.virtual('id').get(function () { userSchema.set('toJSON', { virtuals: true }); +const resolvePermissionSettings = async (user) => { + const permissionSettingsModel = mongoose.model('permissionSettings'); + const items = user?.permissionSettings || []; + const ids = items.map(getPermissionSettingsId).filter(Boolean); + if (ids.length === 0) { + return []; + } + + const docs = await permissionSettingsModel + .find({ _id: { $in: ids } }) + .lean(); + const docsById = new Map(docs.map((doc) => [String(doc._id), doc])); + + return ids.map((id) => docsById.get(String(id))).filter(Boolean); +}; + +userSchema.statics.recalculate = async function (user, actingUser) { + const userId = user?._id || user; + if (!userId) { + return; + } + + const settings = await resolvePermissionSettings(user); + const permissions = applyPermissionSettingsList(settings); + const { editObject } = await import('../../database.js'); + + await editObject({ + model: this, + id: userId, + updateData: { permissions }, + user: actingUser, + populate: ['profileImage', 'permissionSettings'], + recalculate: false, + }); +}; + export const userModel = mongoose.model('user', userSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index b55486a..7a63124 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -24,6 +24,7 @@ import { stockLocationModel } from './inventory/stocklocation.schema.js'; import { stockTransferModel } from './inventory/stocktransfer.schema.js'; import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; +import { permissionSettingsModel } from './management/permissionsettings.schema.js'; import { appPasswordModel } from './management/apppassword.schema.js'; import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; @@ -212,6 +213,13 @@ export const models = { referenceField: '_reference', label: 'User', }, + PMS: { + model: permissionSettingsModel, + idField: '_id', + type: 'permissionSettings', + referenceField: '_reference', + label: 'Permission Settings', + }, APP: { model: appPasswordModel, idField: '_id', diff --git a/src/index.js b/src/index.js index 572729a..a546fc3 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { redisServer } from './database/redis.js'; import { authRoutes, userRoutes, + permissionSettingsRoutes, appPasswordRoutes, fileRoutes, printerRoutes, @@ -154,6 +155,7 @@ app.get('/', function (req, res) { app.use('/auth', authRoutes); app.use('/users', userRoutes); +app.use('/permissionsettings', permissionSettingsRoutes); app.use('/apppasswords', appPasswordRoutes); app.use('/files', fileRoutes); app.use('/spotlight', spotlightRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index 508f56b..b6188eb 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,4 +1,5 @@ import userRoutes from './management/users.js'; +import permissionSettingsRoutes from './management/permissionsettings.js'; import appPasswordRoutes from './management/apppasswords.js'; import fileRoutes from './management/files.js'; import authRoutes from './misc/auth.js'; @@ -60,6 +61,7 @@ import slicerRoutes from './misc/slicer.js'; export { userRoutes, + permissionSettingsRoutes, appPasswordRoutes, fileRoutes, authRoutes, diff --git a/src/routes/management/permissionsettings.js b/src/routes/management/permissionsettings.js new file mode 100644 index 0000000..f2be443 --- /dev/null +++ b/src/routes/management/permissionsettings.js @@ -0,0 +1,99 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; + +const router = express.Router(); + +const listAllowedFilters = ['name', 'createdAt', 'updatedAt', '_reference']; +const listAllowedSorters = ['name', 'createdAt', '_id', 'updatedAt']; +const propertiesAllowedFilters = ['name']; +import { + listPermissionSettingsRouteHandler, + getPermissionSettingsRouteHandler, + editPermissionSettingsRouteHandler, + newPermissionSettingsRouteHandler, + deletePermissionSettingsRouteHandler, + listPermissionSettingsByPropertiesRouteHandler, + getPermissionSettingsStatsRouteHandler, + getPermissionSettingsHistoryRouteHandler, + searchPermissionSettingsRouteHandler, + getPermissionSettingsPropertyValuesRouteHandler, + getPermissionSettingsNeighborsRouteHandler, +} from '../../services/management/permissionsettings.js'; + +router.get('/', isAuthenticated, async (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listPermissionSettingsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sort, listAllowedSorters), + order + ); +}); + +router.get('/properties', isAuthenticated, async (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listPermissionSettingsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.get('/values', isAuthenticated, async (req, res) => { + const { property } = req.query; + getPermissionSettingsPropertyValuesRouteHandler(req, res, property); +}); + +router.get('/search', isAuthenticated, async (req, res) => { + const { search } = req.query; + searchPermissionSettingsRouteHandler(req, res, search); +}); + +router.post('/', isAuthenticated, async (req, res) => { + newPermissionSettingsRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, async (req, res) => { + getPermissionSettingsStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getPermissionSettingsHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sort, order, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getPermissionSettingsNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sort, listAllowedSorters), + order, + id + ); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getPermissionSettingsRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editPermissionSettingsRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deletePermissionSettingsRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/management/users.js b/src/routes/management/users.js index 41125d0..e1e4119 100644 --- a/src/routes/management/users.js +++ b/src/routes/management/users.js @@ -10,6 +10,7 @@ const listAllowedFilters = [ 'firstName', 'lastName', 'email', + 'permissionSettings', 'createdAt', 'updatedAt', '_reference', diff --git a/src/services/management/__tests__/permissionsettings.test.js b/src/services/management/__tests__/permissionsettings.test.js new file mode 100644 index 0000000..4ae626a --- /dev/null +++ b/src/services/management/__tests__/permissionsettings.test.js @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/database.js', () => ({ + searchObjects: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), + getModelStats: jest.fn(), + getModelHistory: jest.fn(), + getObjectNeighbors: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/management/permissionsettings.schema.js', () => ({ + permissionSettingsModel: { modelName: 'PermissionSettings' }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + listPermissionSettingsRouteHandler, + getPermissionSettingsRouteHandler, + newPermissionSettingsRouteHandler, + editPermissionSettingsRouteHandler, +} = await import('../permissionsettings.js'); + +const { listObjects, getObject, editObject, newObject } = await import( + '../../../database/database.js' +); +const { permissionSettingsModel } = await import( + '../../../database/schemas/management/permissionsettings.schema.js' +); + +describe('Permission Settings Service Route Handlers', () => { + let req, res; + + beforeEach(() => { + req = { + params: {}, + query: {}, + body: {}, + user: { id: 'test-user-id' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + }); + + describe('listPermissionSettingsRouteHandler', () => { + it('should list permission settings', async () => { + const mockResult = [{ _id: '1', name: 'Admin' }]; + listObjects.mockResolvedValue(mockResult); + + await listPermissionSettingsRouteHandler(req, res); + + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ model: permissionSettingsModel }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('newPermissionSettingsRouteHandler', () => { + it('should create new permission settings', async () => { + req.body = { name: 'Editor', permissions: { user: { info: true } } }; + const mockPermissionSettings = { _id: '456', ...req.body }; + newObject.mockResolvedValue(mockPermissionSettings); + + await newPermissionSettingsRouteHandler(req, res); + + expect(newObject).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith(mockPermissionSettings); + }); + }); + + describe('editPermissionSettingsRouteHandler', () => { + it('should update permission settings', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { permissions: { user: { edit: true } } }; + const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body }; + editObject.mockResolvedValue(mockResult); + + await editPermissionSettingsRouteHandler(req, res); + + expect(editObject).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('getPermissionSettingsRouteHandler', () => { + it('should get permission settings by id', async () => { + req.params.id = '123'; + const mockResult = { _id: '123', name: 'Admin' }; + getObject.mockResolvedValue(mockResult); + + await getPermissionSettingsRouteHandler(req, res); + + expect(getObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: permissionSettingsModel, + id: '123', + }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); +}); diff --git a/src/services/management/permissionsettings.js b/src/services/management/permissionsettings.js new file mode 100644 index 0000000..58e8c5a --- /dev/null +++ b/src/services/management/permissionsettings.js @@ -0,0 +1,231 @@ +import config from '../../config.js'; +import { permissionSettingsModel } from '../../database/schemas/management/permissionsettings.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; + +const logger = log4js.getLogger('PermissionSettings'); +logger.level = config.server.logLevel; + +export const listPermissionSettingsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: permissionSettingsModel, + page, + limit, + property, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error listing permission settings.'); + res.status(result.code).send(result); + return; + } + + logger.debug( + `List of permission settings (Page ${page}, Limit ${limit}). Count: ${result.length}.` + ); + res.send(result); +}; + +export const listPermissionSettingsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: permissionSettingsModel, + properties, + filter, + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing permission settings.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of permission settings. Count: ${result.length}`); + res.send(result); +}; + +export const getPermissionSettingsPropertyValuesRouteHandler = async (req, res, property) => { + const result = await getPropertyValues({ + model: permissionSettingsModel, + property, + }); + res.send(result); +}; + +export const searchPermissionSettingsRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: permissionSettingsModel, + search, + }); + res.send(result); +}; + +export const getPermissionSettingsRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: permissionSettingsModel, + id, + }); + if (result?.error) { + logger.warn(`Permission settings not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retreived permission settings with ID: ${id}`); + res.send(result); +}; + +export const editPermissionSettingsRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Permission settings with ID: ${id}`); + + const updateData = { + updatedAt: new Date(), + name: req.body.name, + permissions: req.body.permissions, + }; + const result = await editObject({ + model: permissionSettingsModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing permission settings:', result.error); + res.status(result).send(result); + return; + } + + logger.debug(`Edited permission settings with ID: ${id}`); + + res.send(result); +}; + +export const newPermissionSettingsRouteHandler = async (req, res) => { + const newData = { + updatedAt: new Date(), + name: req.body.name, + permissions: req.body.permissions, + }; + const result = await newObject({ + model: permissionSettingsModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No permission settings created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New permission settings with ID: ${result._id}`); + + res.send(result); +}; + +export const deletePermissionSettingsRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Permission settings with ID: ${id}`); + + const result = await deleteObject({ + model: permissionSettingsModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No permission settings deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted permission settings with ID: ${result._id}`); + + res.send(result); +}; + +export const getPermissionSettingsStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: permissionSettingsModel }); + if (result?.error) { + logger.error('Error fetching permission settings stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Permission settings stats:', result); + res.send(result); +}; + +export const getPermissionSettingsHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: permissionSettingsModel, from, to }); + if (result?.error) { + logger.error('Error fetching permission settings history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Permission settings history:', result); + res.send(result); +}; + +export const getPermissionSettingsNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: permissionSettingsModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching permissionSettings neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved permissionSettings neighbors for ID: ${id}`); + res.send(result); +}; diff --git a/src/services/management/users.js b/src/services/management/users.js index 80da829..01bbecf 100644 --- a/src/services/management/users.js +++ b/src/services/management/users.js @@ -19,6 +19,8 @@ import { const logger = log4js.getLogger('Users'); logger.level = config.server.logLevel; +const USER_POPULATE = ['profileImage', 'permissionSettings']; + export const listUsersRouteHandler = async ( req, res, @@ -39,6 +41,7 @@ export const listUsersRouteHandler = async ( search, sort, order, + populate: USER_POPULATE, }); if (result?.error) { @@ -96,7 +99,7 @@ export const getUserRouteHandler = async (req, res) => { const result = await getObject({ model: userModel, id, - populate: ['profileImage'], + populate: USER_POPULATE, }); if (result?.error) { logger.warn(`User not found with supplied id.`); @@ -119,6 +122,9 @@ export const editUserRouteHandler = async (req, res) => { lastName: req.body.lastName, email: req.body.email, profileImage: req.body.profileImage, + permissionSettings: Array.isArray(req.body.permissionSettings) + ? req.body.permissionSettings.map((item) => item?._id || item) + : req.body.permissionSettings, }; // Create audit log before updating const result = await editObject({ @@ -126,7 +132,7 @@ export const editUserRouteHandler = async (req, res) => { id, updateData, user: req.user, - populate: ['profileImage'], + populate: USER_POPULATE, }); if (result.error) {