mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
fix: scope rename rollback to only migrated users, prevent cross-role corruption
Capture user IDs before forward migration so the rollback path only reverts users this request actually moved. Previously the rollback called updateUsersByRole(newName, currentName) which would sweep all users with the new role — including any independently assigned by a concurrent admin request — causing silent cross-role data corruption. Adds findUserIdsByRole and updateUsersRoleByIds to the data layer. Extracts rollbackMigratedUsers helper to deduplicate rollback sites.
This commit is contained in:
parent
1fb205ef54
commit
c89b5db55c
3 changed files with 63 additions and 28 deletions
|
|
@ -67,6 +67,8 @@ function createDeps(overrides: Partial<AdminRolesDeps> = {}): AdminRolesDeps {
|
||||||
findUser: jest.fn().mockResolvedValue(null),
|
findUser: jest.fn().mockResolvedValue(null),
|
||||||
updateUser: jest.fn().mockResolvedValue(mockUser()),
|
updateUser: jest.fn().mockResolvedValue(mockUser()),
|
||||||
updateUsersByRole: jest.fn().mockResolvedValue(undefined),
|
updateUsersByRole: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findUserIdsByRole: jest.fn().mockResolvedValue(['uid-1', 'uid-2']),
|
||||||
|
updateUsersRoleByIds: jest.fn().mockResolvedValue(undefined),
|
||||||
listUsersByRole: jest.fn().mockResolvedValue([]),
|
listUsersByRole: jest.fn().mockResolvedValue([]),
|
||||||
countUsersByRole: jest.fn().mockResolvedValue(0),
|
countUsersByRole: jest.fn().mockResolvedValue(0),
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
@ -455,6 +457,10 @@ describe('createAdminRolesHandlers', () => {
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const deps = createDeps({
|
const deps = createDeps({
|
||||||
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
||||||
|
findUserIdsByRole: jest.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('findUserIdsByRole');
|
||||||
|
return Promise.resolve(['uid-1']);
|
||||||
|
}),
|
||||||
updateUsersByRole: jest.fn().mockImplementation(() => {
|
updateUsersByRole: jest.fn().mockImplementation(() => {
|
||||||
callOrder.push('updateUsersByRole');
|
callOrder.push('updateUsersByRole');
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|
@ -473,8 +479,9 @@ describe('createAdminRolesHandlers', () => {
|
||||||
await handlers.updateRole(req, res);
|
await handlers.updateRole(req, res);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(200);
|
expect(status).toHaveBeenCalledWith(200);
|
||||||
|
expect(deps.findUserIdsByRole).toHaveBeenCalledWith('editor');
|
||||||
expect(deps.updateUsersByRole).toHaveBeenCalledWith('editor', 'new-name');
|
expect(deps.updateUsersByRole).toHaveBeenCalledWith('editor', 'new-name');
|
||||||
expect(callOrder).toEqual(['updateUsersByRole', 'updateRoleByName']);
|
expect(callOrder).toEqual(['findUserIdsByRole', 'updateUsersByRole', 'updateRoleByName']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not rename role when user migration fails', async () => {
|
it('does not rename role when user migration fails', async () => {
|
||||||
|
|
@ -655,8 +662,10 @@ describe('createAdminRolesHandlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls back user migration when rename fails', async () => {
|
it('rolls back user migration when rename fails', async () => {
|
||||||
|
const ids = ['uid-1', 'uid-2'];
|
||||||
const deps = createDeps({
|
const deps = createDeps({
|
||||||
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
||||||
|
findUserIdsByRole: jest.fn().mockResolvedValue(ids),
|
||||||
updateRoleByName: jest.fn().mockResolvedValue(null),
|
updateRoleByName: jest.fn().mockResolvedValue(null),
|
||||||
});
|
});
|
||||||
const handlers = createAdminRolesHandlers(deps);
|
const handlers = createAdminRolesHandlers(deps);
|
||||||
|
|
@ -669,14 +678,16 @@ describe('createAdminRolesHandlers', () => {
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(404);
|
expect(status).toHaveBeenCalledWith(404);
|
||||||
expect(json).toHaveBeenCalledWith({ error: 'Role not found' });
|
expect(json).toHaveBeenCalledWith({ error: 'Role not found' });
|
||||||
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(2);
|
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(1);
|
||||||
expect(deps.updateUsersByRole).toHaveBeenNthCalledWith(1, 'editor', 'new-name');
|
expect(deps.updateUsersByRole).toHaveBeenCalledWith('editor', 'new-name');
|
||||||
expect(deps.updateUsersByRole).toHaveBeenNthCalledWith(2, 'new-name', 'editor');
|
expect(deps.updateUsersRoleByIds).toHaveBeenCalledWith(ids, 'editor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls back user migration when rename throws', async () => {
|
it('rolls back user migration when rename throws', async () => {
|
||||||
|
const ids = ['uid-1', 'uid-2'];
|
||||||
const deps = createDeps({
|
const deps = createDeps({
|
||||||
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
||||||
|
findUserIdsByRole: jest.fn().mockResolvedValue(ids),
|
||||||
updateRoleByName: jest.fn().mockRejectedValue(new Error('db crash')),
|
updateRoleByName: jest.fn().mockRejectedValue(new Error('db crash')),
|
||||||
});
|
});
|
||||||
const handlers = createAdminRolesHandlers(deps);
|
const handlers = createAdminRolesHandlers(deps);
|
||||||
|
|
@ -688,18 +699,16 @@ describe('createAdminRolesHandlers', () => {
|
||||||
await handlers.updateRole(req, res);
|
await handlers.updateRole(req, res);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(2);
|
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(1);
|
||||||
expect(deps.updateUsersByRole).toHaveBeenNthCalledWith(1, 'editor', 'new-name');
|
expect(deps.updateUsersByRole).toHaveBeenCalledWith('editor', 'new-name');
|
||||||
expect(deps.updateUsersByRole).toHaveBeenNthCalledWith(2, 'new-name', 'editor');
|
expect(deps.updateUsersRoleByIds).toHaveBeenCalledWith(ids, 'editor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs rollback failure and still returns 500', async () => {
|
it('logs rollback failure and still returns 500', async () => {
|
||||||
const deps = createDeps({
|
const deps = createDeps({
|
||||||
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
getRoleByName: jest.fn().mockResolvedValueOnce(mockRole()).mockResolvedValueOnce(null),
|
||||||
updateUsersByRole: jest
|
findUserIdsByRole: jest.fn().mockResolvedValue(['uid-1']),
|
||||||
.fn()
|
updateUsersRoleByIds: jest.fn().mockRejectedValue(new Error('rollback failed')),
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockRejectedValueOnce(new Error('rollback failed')),
|
|
||||||
updateRoleByName: jest.fn().mockRejectedValue(new Error('rename failed')),
|
updateRoleByName: jest.fn().mockRejectedValue(new Error('rename failed')),
|
||||||
});
|
});
|
||||||
const handlers = createAdminRolesHandlers(deps);
|
const handlers = createAdminRolesHandlers(deps);
|
||||||
|
|
@ -711,7 +720,8 @@ describe('createAdminRolesHandlers', () => {
|
||||||
await handlers.updateRole(req, res);
|
await handlers.updateRole(req, res);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(2);
|
expect(deps.updateUsersByRole).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.updateUsersRoleByIds).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 when description exceeds max length', async () => {
|
it('returns 400 when description exceeds max length', async () => {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export interface AdminRolesDeps {
|
||||||
) => Promise<IUser | null>;
|
) => Promise<IUser | null>;
|
||||||
updateUser: (userId: string, data: Partial<IUser>) => Promise<IUser | null>;
|
updateUser: (userId: string, data: Partial<IUser>) => Promise<IUser | null>;
|
||||||
updateUsersByRole: (oldRole: string, newRole: string) => Promise<void>;
|
updateUsersByRole: (oldRole: string, newRole: string) => Promise<void>;
|
||||||
|
findUserIdsByRole: (roleName: string) => Promise<string[]>;
|
||||||
|
updateUsersRoleByIds: (userIds: string[], newRole: string) => Promise<void>;
|
||||||
listUsersByRole: (
|
listUsersByRole: (
|
||||||
roleName: string,
|
roleName: string,
|
||||||
options?: { limit?: number; offset?: number },
|
options?: { limit?: number; offset?: number },
|
||||||
|
|
@ -104,6 +106,8 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps) {
|
||||||
findUser,
|
findUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUsersByRole,
|
updateUsersByRole,
|
||||||
|
findUserIdsByRole,
|
||||||
|
updateUsersRoleByIds,
|
||||||
listUsersByRole,
|
listUsersByRole,
|
||||||
countUsersByRole,
|
countUsersByRole,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
@ -176,35 +180,40 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rollbackMigratedUsers(
|
||||||
|
migratedIds: string[],
|
||||||
|
currentName: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (migratedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateUsersRoleByIds(migratedIds, currentName);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
logger.error(
|
||||||
|
`[adminRoles] CRITICAL: rename rollback failed — ${migratedIds.length} users have dangling role "${newName}":`,
|
||||||
|
rollbackError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renameRole(
|
async function renameRole(
|
||||||
currentName: string,
|
currentName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
extraUpdates?: Partial<IRole>,
|
extraUpdates?: Partial<IRole>,
|
||||||
): Promise<IRole | null> {
|
): Promise<IRole | null> {
|
||||||
|
const migratedIds = await findUserIdsByRole(currentName);
|
||||||
await updateUsersByRole(currentName, newName);
|
await updateUsersByRole(currentName, newName);
|
||||||
try {
|
try {
|
||||||
const updates: Partial<IRole> = { name: newName, ...extraUpdates };
|
const updates: Partial<IRole> = { name: newName, ...extraUpdates };
|
||||||
const role = await updateRoleByName(currentName, updates);
|
const role = await updateRoleByName(currentName, updates);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
try {
|
await rollbackMigratedUsers(migratedIds, currentName, newName);
|
||||||
await updateUsersByRole(newName, currentName);
|
|
||||||
} catch (rollbackError) {
|
|
||||||
logger.error(
|
|
||||||
`[adminRoles] CRITICAL: rename rollback failed — users have dangling role "${newName}":`,
|
|
||||||
rollbackError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return role;
|
return role;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
await rollbackMigratedUsers(migratedIds, currentName, newName);
|
||||||
await updateUsersByRole(newName, currentName);
|
|
||||||
} catch (rollbackError) {
|
|
||||||
logger.error(
|
|
||||||
`[adminRoles] CRITICAL: rename rollback failed — users have dangling role "${newName}":`,
|
|
||||||
rollbackError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,20 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
|
||||||
await User.updateMany({ role: oldRole }, { $set: { role: newRole } });
|
await User.updateMany({ role: oldRole }, { $set: { role: newRole } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findUserIdsByRole(roleName: string): Promise<string[]> {
|
||||||
|
const User = mongoose.models.User as Model<IUser>;
|
||||||
|
const users = await User.find({ role: roleName }).select('_id').lean();
|
||||||
|
return users.map((u) => u._id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUsersRoleByIds(userIds: string[], newRole: string): Promise<void> {
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const User = mongoose.models.User as Model<IUser>;
|
||||||
|
await User.updateMany({ _id: { $in: userIds } }, { $set: { role: newRole } });
|
||||||
|
}
|
||||||
|
|
||||||
async function listUsersByRole(
|
async function listUsersByRole(
|
||||||
roleName: string,
|
roleName: string,
|
||||||
options?: { limit?: number; offset?: number },
|
options?: { limit?: number; offset?: number },
|
||||||
|
|
@ -483,6 +497,8 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
|
||||||
createRoleByName,
|
createRoleByName,
|
||||||
deleteRoleByName,
|
deleteRoleByName,
|
||||||
updateUsersByRole,
|
updateUsersByRole,
|
||||||
|
findUserIdsByRole,
|
||||||
|
updateUsersRoleByIds,
|
||||||
listUsersByRole,
|
listUsersByRole,
|
||||||
countUsersByRole,
|
countUsersByRole,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue