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:
Félix Malfait
2026-05-09 11:57:38 +02:00
committed by GitHub
parent 3420d63b7a
commit 459c64f642
3 changed files with 201 additions and 0 deletions

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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,