mirror of
https://github.com/twentyhq/twenty.git
synced 2026-05-25 00:45:27 -04:00
Prevent non-admin users from impersonating admin users (#20412)
## Summary - Adds a privilege check to workspace-level impersonation: non-admin users can no longer impersonate users who have `canAccessFullAdminPanel` or `canImpersonate` flags - Adds the same check in JWT token validation as defense-in-depth (invalidates existing impersonation sessions targeting admin users) - Adds 3 unit tests covering: non-admin → admin blocked, non-admin → canImpersonate blocked, admin → admin allowed ## Test plan - [x] Unit tests pass (14/14 in `impersonation.service.spec.ts`) - [x] Typecheck passes - [ ] Verify workspace-level impersonation of regular users still works normally - [ ] Verify server-level impersonation by admins is unaffected 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -338,6 +338,21 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
const targetHasAdminPrivileges =
|
||||
impersonatedUserWorkspace.user.canImpersonate === true ||
|
||||
impersonatedUserWorkspace.user.canAccessFullAdminPanel === true;
|
||||
|
||||
const impersonatorHasAdminPrivileges =
|
||||
impersonatorUserWorkspace.user.canImpersonate === true ||
|
||||
impersonatorUserWorkspace.user.canAccessFullAdminPanel === true;
|
||||
|
||||
if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) {
|
||||
throw new AuthException(
|
||||
'Cannot impersonate a user with admin privileges',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId,
|
||||
impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId,
|
||||
|
||||
@@ -378,6 +378,177 @@ describe('ImpersonationService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('admin privilege escalation prevention', () => {
|
||||
beforeEach(() => {
|
||||
PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockReset();
|
||||
});
|
||||
|
||||
it('should throw when non-admin tries to impersonate a user with canAccessFullAdminPanel', async () => {
|
||||
const mockToImpersonateUserWorkspace = {
|
||||
userId: 'target-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'target-user-id',
|
||||
email: 'admin@example.com',
|
||||
canAccessFullAdminPanel: true,
|
||||
canImpersonate: false,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
};
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: 'impersonator-user-workspace-id',
|
||||
userId: 'impersonator-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'impersonator-user-id',
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockResolvedValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.impersonate(
|
||||
'target-user-id',
|
||||
'workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when non-admin tries to impersonate a user with canImpersonate', async () => {
|
||||
const mockToImpersonateUserWorkspace = {
|
||||
userId: 'target-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'target-user-id',
|
||||
email: 'admin@example.com',
|
||||
canImpersonate: true,
|
||||
canAccessFullAdminPanel: false,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
};
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: 'impersonator-user-workspace-id',
|
||||
userId: 'impersonator-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'impersonator-user-id',
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockResolvedValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.impersonate(
|
||||
'target-user-id',
|
||||
'workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow admin to impersonate another admin in the same workspace', async () => {
|
||||
const mockToImpersonateUserWorkspace = {
|
||||
userId: 'target-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'target-user-id',
|
||||
email: 'admin@example.com',
|
||||
canAccessFullAdminPanel: true,
|
||||
canImpersonate: false,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
};
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: 'impersonator-user-workspace-id',
|
||||
userId: 'impersonator-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
user: {
|
||||
id: 'impersonator-user-id',
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: true,
|
||||
},
|
||||
workspace: { id: 'workspace-id', allowImpersonation: true },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockResolvedValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
LoginTokenServiceGenerateLoginTokenMock.mockResolvedValueOnce({
|
||||
token: 'mock-login-token',
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.impersonate(
|
||||
'target-user-id',
|
||||
'workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
workspace: {
|
||||
id: 'workspace-id',
|
||||
workspaceUrls: {
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'https://twenty.twenty.com',
|
||||
},
|
||||
},
|
||||
loginToken: {
|
||||
token: 'mock-login-token',
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('2FA requirements for server-level impersonation', () => {
|
||||
it('should allow server-level impersonation when 2FA is enabled and verified', async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
|
||||
@@ -127,6 +127,21 @@ export class ImpersonationService {
|
||||
);
|
||||
}
|
||||
|
||||
const targetHasAdminPrivileges =
|
||||
toImpersonateUserWorkspace.user.canImpersonate === true ||
|
||||
toImpersonateUserWorkspace.user.canAccessFullAdminPanel === true;
|
||||
|
||||
const impersonatorHasAdminPrivileges =
|
||||
impersonatorUserWorkspace.user.canImpersonate === true ||
|
||||
impersonatorUserWorkspace.user.canAccessFullAdminPanel === true;
|
||||
|
||||
if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) {
|
||||
throw new AuthException(
|
||||
'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return this.generateImpersonationLoginToken(
|
||||
impersonatorUserWorkspace,
|
||||
toImpersonateUserWorkspace,
|
||||
|
||||
Reference in New Issue
Block a user