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) {