Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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(() => {
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);

if (!isOwner && !isPublicFromOthers) {
throw new Error("You can only view templates that you own or public templates from others");
}
if (!isAdmin && 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 },
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())) {
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())) {
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