From f83e74eb97efced07731c3c335309bfeb89dd7a7 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 3 Aug 2026 12:16:28 +0200 Subject: [PATCH 1/3] fix : restrict public templates list and view to importable types --- backend/webserver/Socket.js | 30 +++++++++---------- backend/webserver/sockets/template.js | 5 ++++ .../templates/PublicTemplatesModal.vue | 3 ++ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 2b1eab34e..60c806bb7 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -510,28 +510,28 @@ module.exports = class Socket { let fullRowAccess = isPublicOrAdmin; if (!fullRowAccess) { - // --- Ownership: user always sees their own rows when table has userId --- - if (hasUserIdAttribute) { - rowVisibilityConditions.push({userId}); - } - - // --- Public rows: always visible regardless of ownership or access rights --- - if ('public' in model.getAttributes()) { - rowVisibilityConditions.push({public: true}); - } - - // --- User-level row filter --- + // --- User-level row filter (authoritative when present, e.g. template type rules) --- if (hasModelUserFilter) { - const userFilter = await model.getUserFilter(userId); + const userFilter = await model.getUserFilter(userId, isAdmin); if (Reflect.ownKeys(userFilter).length > 0) { rowVisibilityConditions.push(userFilter); } else { // getUserFilter returns {} → grants full row access (e.g. for admins) fullRowAccess = true; } - } else if (!hasUserIdAttribute && accessRights.length === 0) { - this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); - return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; + } else { + // --- Ownership: user always sees their own rows when table has userId --- + if (hasUserIdAttribute) { + rowVisibilityConditions.push({userId}); + } + + // --- Public rows: always visible regardless of ownership or access rights --- + if ('public' in model.getAttributes()) { + rowVisibilityConditions.push({public: true}); + } else if (!hasUserIdAttribute && accessRights.length === 0) { + this.logger.warn("User with id " + userId + " requested table " + tableName + " without access rights"); + return {filter: allFilter, attributes: allAttributes, accessAllowed: false}; + } } // --- Access-map limitations (ORed with user filter conditions) --- diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 5b8135a06..85742dd3b 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -92,10 +92,15 @@ class TemplateSocket extends Socket { const isOwner = template.userId === this.userId; const isPublicFromOthers = template.public === true && !isOwner; + const isAdmin = await this.isAdmin(); + const isEmailType = [1, 2, 3, 6, 7].includes(template.type); if (!isOwner && !isPublicFromOthers) { throw new Error("You can only view templates that you own or public templates from others"); } + if (!isAdmin && !isOwner && isEmailType) { + throw new Error("Access denied: Only administrators can view email templates"); + } const langRow = await this.models["template_content"].findOne({ where: { templateId: data.templateId, language: data.language, deleted: false }, diff --git a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue index 455af5677..d2d3f2213 100644 --- a/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue +++ b/frontend/src/components/dashboard/templates/PublicTemplatesModal.vue @@ -70,8 +70,11 @@ export default { .filter(t => t.userId === this.userId && !t.deleted); }, publicTemplates() { + const importableTypes = [4, 5]; + const isAdmin = this.$store.getters["auth/isAdmin"]; return this.$store.getters["table/template/getAll"] .filter(t => t.public && t.userId !== this.userId && !t.deleted) + .filter(t => isAdmin || importableTypes.includes(t.type)) .map(t => { const alreadyCopied = this.ownTemplates.some( own => own.sourceId === t.id From 446eb9577ab3251c43e124b4edbd108ab51ffe9e Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sun, 9 Aug 2026 16:05:19 +0200 Subject: [PATCH 2/3] fix : require admin for email template view and edit --- backend/db/models/template.js | 7 ++++++- backend/webserver/sockets/template.js | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/db/models/template.js b/backend/db/models/template.js index fd9e65863..4bfec943a 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -51,7 +51,12 @@ module.exports = (sequelize, DataTypes) => { return baseFilter; } const copies = await Template.findAll({ - where: { userId, sourceId: { [Op.ne]: null }, deleted: false }, + where: { + userId, + sourceId: { [Op.ne]: null }, + deleted: false, + type: { [Op.in]: [4, 5] }, + }, attributes: ["sourceId"], raw: true, }); diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 85742dd3b..12ecb18c7 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -98,7 +98,7 @@ class TemplateSocket extends Socket { if (!isOwner && !isPublicFromOthers) { throw new Error("You can only view templates that you own or public templates from others"); } - if (!isAdmin && !isOwner && isEmailType) { + if (!isAdmin && isEmailType) { throw new Error("Access denied: Only administrators can view email templates"); } @@ -199,6 +199,10 @@ class TemplateSocket extends Socket { throw new Error("Copied templates cannot be edited"); } + if ([1, 2, 3, 6, 7].includes(template.type) && !(await this.isAdmin())) { + throw new Error("Access denied: Only administrators can edit email templates"); + } + const bulkEdits = data.ops.map((op, idx) => ({ userId: this.userId, templateId: data.templateId, @@ -594,6 +598,10 @@ class TemplateSocket extends Socket { const template = await this.models["template"].getById(data.templateId); if (!template) return; + if ([1, 2, 3, 6, 7].includes(template.type) && !(await this.isAdmin())) { + throw new Error("Access denied: Only administrators can edit email templates"); + } + if (template.userId === this.userId) { await this.saveTemplate(data.templateId, data.language, options); } @@ -716,6 +724,9 @@ class TemplateSocket extends Socket { const copy = await this.models["template"].getById(data.templateId); if (!copy) throw new Error("Template not found"); if (copy.userId !== this.userId) throw new Error("You can only update your own copies"); + if ([1, 2, 3, 6, 7].includes(copy.type) && !(await this.isAdmin())) { + throw new Error("Access denied: Only administrators can update email templates from source"); + } return await this.models["template"].updateFromSource( data.templateId, From de660565f899e45402ad7ba8dfef068c9e5c46e8 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Sun, 9 Aug 2026 16:05:51 +0200 Subject: [PATCH 3/3] fix : refresh rights after role changes mid-session --- backend/db/models/user.js | 8 ++++ backend/db/models/user_role_matching.js | 2 +- backend/webserver/Socket.js | 63 +++++++++++++++++-------- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/backend/db/models/user.js b/backend/db/models/user.js index b56cb3b83..a19a2f08d 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -475,6 +475,14 @@ module.exports = (sequelize, DataTypes) => { } } } + + // SequelizeSimpleCache keeps User rows indefinitely (ttl: false). After roles + // change, clear it so the next findByPk sees the new rolesUpdatedAt. + options.transaction.afterCommit(() => { + if (User.cache) { + User.cache.clear(); + } + }); } /** diff --git a/backend/db/models/user_role_matching.js b/backend/db/models/user_role_matching.js index 024c0c426..fba9cc32d 100644 --- a/backend/db/models/user_role_matching.js +++ b/backend/db/models/user_role_matching.js @@ -25,7 +25,7 @@ module.exports = (sequelize, DataTypes) => { */ static async getUserRolesById(userId) { const userRoles = await sequelize.models.user_role_matching.findAll({ - where: {userId: userId}, + where: {userId: userId, deleted: false}, raw: true, }); return userRoles.map((role) => role.userRoleId); diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 60c806bb7..6174c0831 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -36,7 +36,7 @@ module.exports = class Socket { .filter((model) => model.autoTable) .map((model) => model.tableName); - // user rights in form: userId: {isAdmin: false, rights: {right1: false, ..}, roles: [role1, ..], lastRolesUpdate: Date} + // user rights in form: userId: {isAdmin: false, rights: {right1: false, ..}, roles: [role1, ..], lastRolesUpdate: Date, rolesUpdatedAtMs} this.userInfo = {}; this.transactionMonitor = new EWMAMonitor(30, this.logger); @@ -250,19 +250,45 @@ module.exports = class Socket { } + /** + * Resolve the user's rolesUpdatedAt as a millisecond timestamp. + * Prefers the DB value so mid-session role changes are visible; falls back to the hint. + * Relies on User.cache being cleared when roles change. + * @param {number} userId The user id + * @param {Date} [rolesUpdatedAt] Date of the last role update of the user + * @returns {Promise} Millisecond timestamp of user.rolesUpdatedAt, or null if unavailable + */ + async getRolesUpdatedAtMs(userId, rolesUpdatedAt = null) { + try { + const user = await this.models["user"].findByPk(userId, { + attributes: ["rolesUpdatedAt"], + raw: true, + }); + if (user?.rolesUpdatedAt) { + return new Date(user.rolesUpdatedAt).getTime(); + } + } catch (_err) { + // fall through to hint + } + if (rolesUpdatedAt) { + return new Date(rolesUpdatedAt).getTime(); + } + return null; + } + /** * Checks and caches whether the user is an admin. - * Note: This method has side effects as it caches the admin status in `this.userInfo[userId].isUserAdmin`. - * This can be problematic if the user's admin status changes - * during their session, as the cached value won't automatically update. + * Reloads roles when DB user.rolesUpdatedAt differs from the cached timestamp so + * mid-session role changes are enforced without reconnecting. * @param {number} userId The id of the user to check admin privileges for * @param {Date} rolesUpdatedAt Date of the last role update of the user * @returns {Promise} True if the user is an admin. */ async isAdmin(userId = this.userId, rolesUpdatedAt = this.rolesUpdatedAt) { - // admin has full rights, so return true directly - if (!this.userInfo[userId] || rolesUpdatedAt > this.userInfo[userId].lastRolesUpdate) { - await this.updateUserInfo(userId); + const rolesUpdatedAtMs = await this.getRolesUpdatedAtMs(userId, rolesUpdatedAt); + const cached = this.userInfo[userId]; + if (!cached || cached.rolesUpdatedAtMs !== rolesUpdatedAtMs) { + await this.updateUserInfo(userId, rolesUpdatedAtMs); } return this.userInfo[userId].isAdmin; } @@ -270,15 +296,17 @@ module.exports = class Socket { /** * Adds access information about the user userId in this.userInfo. * @param {number} userId The id of the user to update access for - * @returns {void} + * @param {number|null} [rolesUpdatedAtMs] Millisecond timestamp of user.rolesUpdatedAt when known + * @returns {Promise} */ - async updateUserInfo(userId) { + async updateUserInfo(userId, rolesUpdatedAtMs = null) { const userAccess = {}; const roleIds = await this.models["user_role_matching"].getUserRolesById(userId); userAccess.roles = roleIds; userAccess.isAdmin = await this.models["user_role_matching"].isAdminInUserRoles(roleIds); userAccess.rights = {}; userAccess.lastRolesUpdate = new Date(); + userAccess.rolesUpdatedAtMs = rolesUpdatedAtMs; this.userInfo[userId] = userAccess; } @@ -290,21 +318,18 @@ module.exports = class Socket { * @returns {Promise} True if the user has the right */ async hasAccess(right, userId = this.userId, rolesUpdatedAt = this.rolesUpdatedAt) { - // admin has full rights, so return true directly - if (!this.userInfo[userId] || rolesUpdatedAt > this.userInfo[userId].lastRolesUpdate) { - await this.updateUserInfo(userId); + // isAdmin refreshes the rights cache when rolesUpdatedAt changed + if (await this.isAdmin(userId, rolesUpdatedAt)) { + return true; } const userInfo = this.userInfo[userId]; - if (userInfo.isAdmin) { - return true; - } else if (userInfo.rights[right]) { + if (userInfo.rights[right] !== undefined) { return userInfo.rights[right]; - } else { - const hasAccess = await this.models["user_role_matching"].hasAccessByUserRoles(userInfo.roles, right); - this.userInfo[userId].rights[right] = hasAccess; - return hasAccess; } + const hasAccess = await this.models["user_role_matching"].hasAccessByUserRoles(userInfo.roles, right); + this.userInfo[userId].rights[right] = hasAccess; + return hasAccess; } /**