From 459c64f642f6aee95de8f2e67b01e1f9db0720e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 9 May 2026 11:57:38 +0200 Subject: [PATCH] Prevent non-admin users from impersonating admin users (#20412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../auth/strategies/jwt.auth.strategy.ts | 15 ++ .../__tests__/impersonation.service.spec.ts | 171 ++++++++++++++++++ .../services/impersonation.service.ts | 15 ++ 3 files changed, 201 insertions(+) diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index ce878c8bbd3..7553388757f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts index 83ee8aeae17..606c22282d5 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts @@ -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) => { diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts index 0292e73d045..b1b32bb039b 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts @@ -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,