Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/db/models/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
8 changes: 8 additions & 0 deletions backend/db/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Comment thread
mohammadsherif0 marked this conversation as resolved.
if (User.cache) {
User.cache.clear();
}
});
}

/**
Expand Down
2 changes: 1 addition & 1 deletion backend/db/models/user_role_matching.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
93 changes: 59 additions & 34 deletions backend/webserver/Socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -250,35 +250,63 @@ 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<boolean>} 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;
}

/**
* 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<void>}
*/
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;
}

Expand All @@ -290,21 +318,18 @@ module.exports = class Socket {
* @returns {Promise<boolean>} 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;
}

/**
Expand Down Expand Up @@ -510,28 +535,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) ---
Expand Down
16 changes: 16 additions & 0 deletions backend/webserver/sockets/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
mohammadsherif0 marked this conversation as resolved.
Outdated

if (!isOwner && !isPublicFromOthers) {
throw new Error("You can only view templates that you own or public templates from others");
}
if (!isAdmin && isEmailType) {
Comment thread
mohammadsherif0 marked this conversation as resolved.
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 },
Expand Down Expand Up @@ -194,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())) {
Comment thread
mohammadsherif0 marked this conversation as resolved.
Outdated
throw new Error("Access denied: Only administrators can edit email templates");
}

const bulkEdits = data.ops.map((op, idx) => ({
userId: this.userId,
templateId: data.templateId,
Expand Down Expand Up @@ -589,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())) {
Comment thread
mohammadsherif0 marked this conversation as resolved.
Outdated
throw new Error("Access denied: Only administrators can edit email templates");
}

if (template.userId === this.userId) {
await this.saveTemplate(data.templateId, data.language, options);
}
Expand Down Expand Up @@ -711,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading