diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 52f21253b6..05ad4bcb32 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -2633,6 +2633,7 @@ export enum PermissionFlagType {
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
DATA_MODEL = 'DATA_MODEL',
EXPORT_CSV = 'EXPORT_CSV',
+ IMPERSONATE = 'IMPERSONATE',
IMPORT_CSV = 'IMPORT_CSV',
ROLES = 'ROLES',
SECURITY = 'SECURITY',
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index db87d866a1..5921a0ae45 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -2544,6 +2544,7 @@ export enum PermissionFlagType {
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
DATA_MODEL = 'DATA_MODEL',
EXPORT_CSV = 'EXPORT_CSV',
+ IMPERSONATE = 'IMPERSONATE',
IMPORT_CSV = 'IMPORT_CSV',
ROLES = 'ROLES',
SECURITY = 'SECURITY',
diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
index 361322ca2b..665ee4a2a7 100644
--- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
+++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
@@ -22,6 +22,7 @@ import { type AuthTokenPair } from '~/generated/graphql';
import { logDebug } from '~/utils/logDebug';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
+import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import {
@@ -35,7 +36,6 @@ import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { type ApolloManager } from '../types/apolloManager.interface';
-import { getTokenPair } from '../utils/getTokenPair';
import { loggerLink } from '../utils/loggerLink';
import { StreamingRestLink } from '../utils/streamingRestLink';
diff --git a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts
index 5a63546d52..85381369cc 100644
--- a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts
+++ b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts
@@ -1,16 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
import { type AuthTokenPair } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
-
-const isValidAuthTokenPair = (tokenPair: any): tokenPair is AuthTokenPair => {
- return (
- tokenPair &&
- typeof tokenPair === 'object' &&
- tokenPair.accessOrWorkspaceAgnosticToken &&
- typeof tokenPair.accessOrWorkspaceAgnosticToken === 'object' &&
- typeof tokenPair.accessOrWorkspaceAgnosticToken.token === 'string'
- );
-};
+import { isValidAuthTokenPair } from './isValidAuthTokenPair';
export const getTokenPair = (): AuthTokenPair | undefined => {
const stringTokenPair = cookieStorage.getItem('tokenPair');
diff --git a/packages/twenty-front/src/modules/apollo/utils/isValidAuthTokenPair.ts b/packages/twenty-front/src/modules/apollo/utils/isValidAuthTokenPair.ts
new file mode 100644
index 0000000000..43896d19c6
--- /dev/null
+++ b/packages/twenty-front/src/modules/apollo/utils/isValidAuthTokenPair.ts
@@ -0,0 +1,13 @@
+import { type AuthTokenPair } from '~/generated/graphql';
+
+export const isValidAuthTokenPair = (
+ tokenPair: any,
+): tokenPair is AuthTokenPair => {
+ return (
+ tokenPair &&
+ typeof tokenPair === 'object' &&
+ tokenPair.accessOrWorkspaceAgnosticToken &&
+ typeof tokenPair.accessOrWorkspaceAgnosticToken === 'object' &&
+ typeof tokenPair.accessOrWorkspaceAgnosticToken.token === 'string'
+ );
+};
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index 53631b734a..4f2e559baf 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -69,6 +69,7 @@ import { loginTokenState } from '../states/loginTokenState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setLoginToken = useSetRecoilState(loginTokenState);
+
const { origin } = useOrigin();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
diff --git a/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
new file mode 100644
index 0000000000..9bcc7fcba4
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
@@ -0,0 +1,63 @@
+import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
+import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
+import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
+import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
+import { t } from '@lingui/core/macro';
+import { IconDotsVertical, IconSpy, IconTrash } from 'twenty-ui/display';
+import { LightIconButton } from 'twenty-ui/input';
+import { MenuItem } from 'twenty-ui/navigation';
+import { PermissionFlagType } from '~/generated/graphql';
+
+type ManageMembersDropdownMenuProps = {
+ dropdownId: string;
+ workspaceMember: WorkspaceMember;
+ onDelete: (workspaceMemberId: string) => void;
+ onImpersonate: (workspaceMember: WorkspaceMember) => void;
+};
+
+export const ManageMembersDropdownMenu = ({
+ dropdownId,
+ workspaceMember,
+ onDelete,
+ onImpersonate,
+}: ManageMembersDropdownMenuProps) => {
+ const { closeDropdown } = useCloseDropdown();
+ const canImpersonate = useHasPermissionFlag(PermissionFlagType.IMPERSONATE);
+
+ return (
+
+ }
+ dropdownComponents={
+
+
+ {canImpersonate && (
+
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/roles/hooks/useHasPermissionFlag.ts b/packages/twenty-front/src/modules/settings/roles/hooks/useHasPermissionFlag.ts
index 972c49cfa0..a2fcd153ad 100644
--- a/packages/twenty-front/src/modules/settings/roles/hooks/useHasPermissionFlag.ts
+++ b/packages/twenty-front/src/modules/settings/roles/hooks/useHasPermissionFlag.ts
@@ -1,8 +1,10 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
-import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
-import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
-import { PermissionFlagType } from '~/generated/graphql';
+import {
+ WorkspaceActivationStatus,
+ PermissionFlagType,
+} from '~/generated/graphql';
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
export const useHasPermissionFlag = (permissionFlag?: PermissionFlagType) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -20,11 +22,6 @@ export const useHasPermissionFlag = (permissionFlag?: PermissionFlagType) => {
return true;
}
- const currentUserWorkspaceSetting = currentUserWorkspace?.permissionFlags;
-
- if (!currentUserWorkspaceSetting) {
- return false;
- }
-
- return currentUserWorkspaceSetting.includes(permissionFlag);
+ const userFlags = currentUserWorkspace?.permissionFlags ?? [];
+ return userFlags.includes(permissionFlag);
};
diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx
index d815ce2a8c..40887dd23d 100644
--- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx
+++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx
@@ -14,6 +14,7 @@ import {
IconLockOpen,
IconSettings,
IconSettingsAutomation,
+ IconSpy,
IconUsers,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
@@ -89,6 +90,12 @@ export const SettingsRolePermissionsSettingsSection = ({
description: t`Manage workflows`,
Icon: IconSettingsAutomation,
},
+ {
+ key: PermissionFlagType.IMPERSONATE,
+ name: t`Impersonate`,
+ description: t`Impersonate workspace users`,
+ Icon: IconSpy,
+ },
];
return (
diff --git a/packages/twenty-front/src/modules/settings/roles/types/RoleWithPartialMembers.ts b/packages/twenty-front/src/modules/settings/roles/types/RoleWithPartialMembers.ts
index a0293d2052..b1eabe0eb2 100644
--- a/packages/twenty-front/src/modules/settings/roles/types/RoleWithPartialMembers.ts
+++ b/packages/twenty-front/src/modules/settings/roles/types/RoleWithPartialMembers.ts
@@ -1,4 +1,4 @@
-import { type Role, type WorkspaceMember } from '~/generated/graphql';
+import { type Role, type WorkspaceMember } from '~/generated-metadata/graphql';
export type PartialWorkspaceMember = Omit<
WorkspaceMember,
diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
index f72ca627ea..86627cd5a0 100644
--- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
@@ -12,7 +12,9 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
+import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { ManageMembersDropdownMenu } from '@/settings/members/ManageMembersDropdownMenu';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
@@ -40,7 +42,10 @@ import {
} from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
-import { useGetWorkspaceInvitationsQuery } from '~/generated-metadata/graphql';
+import {
+ useGetWorkspaceInvitationsQuery,
+ useImpersonateMutation,
+} from '~/generated-metadata/graphql';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
@@ -105,6 +110,8 @@ export const SettingsWorkspaceMembers = () => {
string | undefined
>();
const [isFetchingMore, setIsFetchingMore] = useState(false);
+ const [impersonate] = useImpersonateMutation();
+ const { executeImpersonationAuth } = useImpersonationAuth();
const [searchFilter, setSearchFilter] = useState('');
const [debouncedSearchFilter] = useDebounce(searchFilter, 300);
@@ -148,6 +155,35 @@ export const SettingsWorkspaceMembers = () => {
const handleRemoveWorkspaceMember = async (workspaceMemberId: string) => {
await deleteOneWorkspaceMember?.(workspaceMemberId);
+ setWorkspaceMemberToDelete(undefined);
+ };
+
+ const handleImpersonate = async (targetWorkspaceMember: WorkspaceMember) => {
+ if (!targetWorkspaceMember.userId || !currentWorkspace?.id) {
+ enqueueErrorSnackBar({
+ message: t`Cannot impersonate selected user`,
+ options: { duration: 2000 },
+ });
+ return;
+ }
+
+ await impersonate({
+ variables: {
+ userId: targetWorkspaceMember.userId,
+ workspaceId: currentWorkspace.id,
+ },
+ onCompleted: async (data) => {
+ const { loginToken } = data.impersonate;
+ await executeImpersonationAuth(loginToken.token);
+ return;
+ },
+ onError: () => {
+ enqueueErrorSnackBar({
+ message: t`Cannot impersonate selected user`,
+ options: { duration: 2000 },
+ });
+ },
+ });
};
const workspaceInvitations = useRecoilValue(workspaceInvitationsState);
@@ -407,14 +443,14 @@ export const SettingsWorkspaceMembers = () => {
{currentWorkspaceMember?.id !== workspaceMember.id && (
- {
+ {
+ setWorkspaceMemberToDelete(id);
openModal(WORKSPACE_MEMBER_DELETION_MODAL_ID);
- setWorkspaceMemberToDelete(workspaceMember.id);
}}
- variant="tertiary"
- size="medium"
- Icon={IconTrash}
/>
)}
diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts
index 477e748793..c58366460c 100644
--- a/packages/twenty-server/@types/express.d.ts
+++ b/packages/twenty-server/@types/express.d.ts
@@ -1,6 +1,7 @@
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
+import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type User } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -18,5 +19,6 @@ declare module 'express-serve-static-core' {
workspaceMemberId?: string;
userWorkspaceId?: string;
authProvider?: AuthProviderEnum | null;
+ impersonationContext?: AuthContext['impersonationContext'];
}
}
diff --git a/packages/twenty-server/@types/jest.d.ts b/packages/twenty-server/@types/jest.d.ts
index 6f982c1159..fe32702efc 100644
--- a/packages/twenty-server/@types/jest.d.ts
+++ b/packages/twenty-server/@types/jest.d.ts
@@ -16,6 +16,7 @@ declare module '@jest/types' {
APPLE_JONY_MEMBER_ACCESS_TOKEN: string;
APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
ACME_JONY_MEMBER_ACCESS_TOKEN: string;
+ APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
API_KEY_ACCESS_TOKEN: string;
testDataSource?: DataSource;
}
@@ -30,6 +31,7 @@ declare global {
const APPLE_JONY_MEMBER_ACCESS_TOKEN: string;
const APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string;
+ const APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
const ACME_JONY_MEMBER_ACCESS_TOKEN: string;
const WORKSPACE_AGNOSTIC_TOKEN: string;
diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts
index d7d180d33e..fe59d50db5 100644
--- a/packages/twenty-server/jest-integration.config.ts
+++ b/packages/twenty-server/jest-integration.config.ts
@@ -93,6 +93,8 @@ const jestConfig: JestConfigWithTsJest = {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjNiOGU2NDU4LTVmYzEtNGU2My04NTYzLTAwOGNjZGRhYTZkYiIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtZTEwYS00YzI3LWE5MGItYjA4YzU3YjAyZDQ1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUyMDc4MDA0LCJleHAiOjMzMzA5Njc4MDA0fQ.JBtQCkNWsqAkzouxhcVjCEikV6A_-qr3IflE67NYQYY',
API_KEY_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQ0OTgzNzUwLCJleHAiOjQ4OTg1ODM2OTMsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.4xkkwz_uu2xzs_V8hJSaM15fGziT5zS3vq2lM48OHr0',
+ APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN:
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6ImltcGVyc29uYXRpb24iLCJpc0ltcGVyc29uYXRpbmciOnRydWUsImltcGVyc29uYXRvclVzZXJXb3Jrc3BhY2VJZCI6IjMxMzEzMTMxLTAwMDEtNDAwMC04MDAwLTAwMDAwMDAwMDAwMCIsImltcGVyc29uYXRlZFVzZXJXb3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTllM2ItNDZkNC1hNTU2LTg4YjlkZGMyYjAzNSIsImlhdCI6MTc1ODU1NDY2NSwiZXhwIjoyNzA1MjgyNjY1fQ.PHXdd0RB2M4YbJRIJQY43ZxAxOE2nU7YzPG-BkdNrQc',
},
};
diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts
index 1d6c407a80..25b00408ec 100644
--- a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts
@@ -5,10 +5,6 @@ import axios from 'axios';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
-import {
- AuthException,
- AuthExceptionCode,
-} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -96,85 +92,6 @@ describe('AdminPanelService', () => {
expect(service).toBeDefined();
});
- it('should impersonate a user and return workspace and loginToken on success', async () => {
- const mockUser = {
- id: 'user-id',
- email: 'user@example.com',
- userWorkspaces: [
- {
- workspace: {
- id: 'workspace-id',
- allowImpersonation: true,
- subdomain: 'example-subdomain',
- },
- },
- ],
- };
-
- UserFindOneMock.mockReturnValueOnce(mockUser);
- LoginTokenServiceGenerateLoginTokenMock.mockReturnValueOnce({
- token: 'mock-login-token',
- expiresAt: new Date(),
- });
-
- const result = await service.impersonate(
- 'user-id',
- 'workspace-id',
- 'user-id',
- );
-
- expect(UserFindOneMock).toHaveBeenCalledWith(
- expect.objectContaining({
- where: expect.objectContaining({
- id: 'user-id',
- userWorkspaces: {
- workspaceId: 'workspace-id',
- workspace: { allowImpersonation: true },
- },
- }),
- relations: { userWorkspaces: { workspace: true } },
- }),
- );
-
- expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
- 'user@example.com',
- 'workspace-id',
- 'impersonation',
- { impersonatorUserId: 'user-id' },
- );
-
- expect(result).toEqual(
- expect.objectContaining({
- workspace: {
- id: 'workspace-id',
- workspaceUrls: {
- customUrl: undefined,
- subdomainUrl: 'https://twenty.twenty.com',
- },
- },
- loginToken: expect.objectContaining({
- token: 'mock-login-token',
- expiresAt: expect.any(Date),
- }),
- }),
- );
- });
-
- it('should throw an error when user is not found', async () => {
- UserFindOneMock.mockReturnValueOnce(null);
-
- await expect(
- service.impersonate('invalid-user-id', 'workspace-id', 'user-id'),
- ).rejects.toThrow(
- new AuthException(
- 'User not found in workspace or impersonation not enabled',
- AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
- ),
- );
-
- expect(UserFindOneMock).toHaveBeenCalled();
- });
-
describe('getConfigVariablesGrouped', () => {
it('should correctly group and sort config variables', () => {
TwentyConfigServiceGetAllMock.mockReturnValue({
diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts
index 910b577b9c..2ffbf7129f 100644
--- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts
@@ -10,9 +10,11 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { HealthModule } from 'src/engine/core-modules/health/health.module';
+import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { User } from 'src/engine/core-modules/user/user.entity';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
@@ -25,6 +27,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
FeatureFlagModule,
AuditModule,
TelemetryModule,
+ ImpersonationModule,
+ PermissionsModule,
],
providers: [AdminPanelResolver, AdminPanelService, AdminPanelHealthService],
exports: [AdminPanelService],
diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts
index b75c425ac4..76b9a6d449 100644
--- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts
@@ -7,18 +7,12 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ConfigVariable } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
-import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
-import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
import { VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
-import {
- AuthException,
- AuthExceptionCode,
-} from 'src/engine/core-modules/auth/auth.exception';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@@ -30,10 +24,7 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
-import { User } from 'src/engine/core-modules/user/user.entity';
-import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
-import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -55,27 +46,7 @@ export class AdminPanelResolver {
private readonly twentyConfigService: TwentyConfigService,
) {}
- @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
- @Mutation(() => ImpersonateOutput)
- async impersonate(
- @Args() { workspaceId, userId }: ImpersonateInput,
- @AuthUser() adminUser: User,
- ): Promise {
- if (!adminUser.id) {
- throw new AuthException(
- 'Admin user not found',
- AuthExceptionCode.UNAUTHENTICATED,
- );
- }
-
- return await this.adminService.impersonate(
- userId,
- workspaceId,
- adminUser.id,
- );
- }
-
- @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
+ @UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => UserLookup)
async userLookupAdminPanel(
@Args() userLookupInput: UserLookupInput,
@@ -83,7 +54,7 @@ export class AdminPanelResolver {
return await this.adminService.userLookup(userLookupInput.userIdentifier);
}
- @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
+ @UseGuards(WorkspaceAuthGuard, UserAuthGuard)
@Mutation(() => Boolean)
async updateWorkspaceFeatureFlag(
@Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput,
diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts
index 3633bc39d4..059c9606c3 100644
--- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts
@@ -12,7 +12,6 @@ import { type ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
import { type VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
-import { MONITORING_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
import {
AuthException,
AuthExceptionCode,
@@ -27,7 +26,6 @@ import { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
-import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
export class AdminPanelService {
@@ -40,83 +38,6 @@ export class AdminPanelService {
private readonly userRepository: Repository,
) {}
- async impersonate(
- userId: string,
- workspaceId: string,
- impersonatorUserId: string,
- ) {
- const user = await this.userRepository.findOne({
- where: {
- id: userId,
- userWorkspaces: {
- workspaceId,
- workspace: {
- allowImpersonation: true,
- },
- },
- },
- relations: { userWorkspaces: { workspace: true } },
- });
-
- userValidator.assertIsDefinedOrThrow(
- user,
- new AuthException(
- 'User not found in workspace or impersonation not enabled',
- AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
- ),
- );
-
- const auditService = this.auditService.createContext({
- workspaceId: user.userWorkspaces[0].workspace.id,
- userId: impersonatorUserId,
- });
-
- await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.attempt',
- message: `Impersonation attempt: targetUserId=${userId}, workspaceId=${workspaceId}, impersonatorUserId=${impersonatorUserId}`,
- });
-
- try {
- await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.login_token_attempt',
- message: `Impersonation token generation attempt for user ${userId}`,
- });
-
- const loginToken = await this.loginTokenService.generateLoginToken(
- user.email,
- user.userWorkspaces[0].workspace.id,
- AuthProviderEnum.Impersonation,
- {
- impersonatorUserId,
- },
- );
-
- await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.login_token_generated',
- message: `Impersonation token generated successfully for user ${userId}`,
- });
-
- return {
- workspace: {
- id: user.userWorkspaces[0].workspace.id,
- workspaceUrls: this.domainManagerService.getWorkspaceUrls(
- user.userWorkspaces[0].workspace,
- ),
- },
- loginToken,
- };
- } catch {
- await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.login_token_failed',
- message: `Impersonation token generation failed for targetUserId=${userId}`,
- });
- throw new AuthException(
- 'Impersonation failed',
- AuthExceptionCode.INVALID_DATA,
- );
- }
- }
-
async userLookup(userIdentifier: string): Promise {
const isEmail = userIdentifier.includes('@');
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
index 13cdf8612d..39b826e1a5 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
@@ -15,6 +15,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
+import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@@ -46,6 +47,10 @@ describe('AuthResolver', () => {
provide: getRepositoryToken(User),
useValue: {},
},
+ {
+ provide: getRepositoryToken(UserWorkspace),
+ useValue: {},
+ },
{
provide: AuthService,
useValue: {},
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
index 97775968a7..36dfb3c3a9 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
@@ -5,8 +5,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import omit from 'lodash.omit';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
+import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
-import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@@ -73,6 +73,7 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
@@ -100,6 +101,8 @@ export class AuthResolver {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository,
+ @InjectRepository(UserWorkspace)
+ private readonly userWorkspaceRepository: Repository,
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository,
private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
@@ -120,6 +123,7 @@ export class AuthResolver {
private emailVerificationTokenService: EmailVerificationTokenService,
private sSOService: SSOService,
private readonly auditService: AuditService,
+ private readonly permissionsService: PermissionsService,
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard)
@@ -559,20 +563,36 @@ export class AuthResolver {
);
if (tokenPayload.authProvider === AuthProviderEnum.Impersonation) {
- await this.validateAndLogImpersonation(
+ const {
+ workspaceId,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
+ impersonatorUserId,
+ impersonatedUserId,
+ } = await this.validateAndLogImpersonation(
tokenPayload,
workspace,
user.email,
);
+
+ return await this.authService.generateImpersonationAccessTokenAndRefreshToken(
+ {
+ workspaceId,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
+ impersonatorUserId,
+ impersonatedUserId,
+ },
+ );
} else {
await this.validateRegularAuthentication(workspace, userWorkspace);
- }
- return await this.authService.verify(
- user.email,
- workspace.id,
- tokenPayload.authProvider,
- );
+ return await this.authService.verify(
+ user.email,
+ workspace.id,
+ tokenPayload.authProvider,
+ );
+ }
}
private async validateAndDecodeLoginToken(
@@ -639,79 +659,97 @@ export class AuthResolver {
tokenPayload: LoginTokenJwtPayload,
workspace: Workspace,
targetUserEmail: string,
- ): Promise {
- const { impersonatorUserId } = tokenPayload;
+ ) {
+ const { impersonatorUserWorkspaceId } = tokenPayload;
+
+ const impersonatorUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: { id: impersonatorUserWorkspaceId },
+ relations: ['user', 'workspace'],
+ });
+
+ const toImpersonateUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: {
+ user: { email: targetUserEmail },
+ workspaceId: workspace.id,
+ },
+ relations: ['user', 'workspace'],
+ });
+
+ if (
+ !isDefined(impersonatorUserWorkspace) ||
+ !isDefined(toImpersonateUserWorkspace)
+ ) {
+ throw new AuthException(
+ 'Impersonator or target user workspace not found',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ const isServerLevelImpersonation =
+ toImpersonateUserWorkspace.workspace.id !==
+ impersonatorUserWorkspace.workspace.id;
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
- userId: impersonatorUserId,
+ userId: impersonatorUserWorkspace.user.id,
});
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.token_exchange_attempt',
- message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserId}`,
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_attempt`,
+ message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
});
- if (workspace.allowImpersonation !== true) {
- throw new AuthException(
- 'Impersonation not allowed on this workspace',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
- }
+ const hasServerLevelImpersonatePermission =
+ impersonatorUserWorkspace.user.canImpersonate === true &&
+ impersonatorUserWorkspace.workspace.allowImpersonation === true;
- if (!impersonatorUserId) {
+ if (isServerLevelImpersonation && !hasServerLevelImpersonatePermission) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
- message: `Invalid impersonation token (missing impersonator user ID) for ${targetUserEmail}`,
+ message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
+
throw new AuthException(
- 'Invalid impersonation token (missing impersonator user ID)',
+ 'Server level impersonation not allowed on this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
- const impersonatorUser = await this.userRepository.findOne({
- where: { id: impersonatorUserId },
- });
+ const hasWorkspaceLevelImpersonatePermission =
+ await this.permissionsService.userHasWorkspaceSettingPermission({
+ userWorkspaceId: impersonatorUserWorkspace.id,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId: workspace.id,
+ });
- if (!impersonatorUser) {
+ if (
+ !hasWorkspaceLevelImpersonatePermission &&
+ !hasServerLevelImpersonatePermission
+ ) {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.token_exchange_failed',
- message: `Impersonator user not found: ${impersonatorUserId} for ${targetUserEmail}`,
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_failed`,
+ message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
throw new AuthException(
- 'Impersonator user not found',
+ 'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
- if (impersonatorUser.canImpersonate !== true) {
- await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.token_exchange_failed',
- message: `User not authorized to impersonate: ${impersonatorUserId} for ${targetUserEmail}`,
- });
- throw new AuthException(
- 'User not authorized to impersonate',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
- }
-
- await this.logImpersonationEvent(workspace.id, impersonatorUserId);
- }
-
- private async logImpersonationEvent(
- workspaceId: string,
- impersonatorUserId: string,
- ): Promise {
- const auditService = this.auditService.createContext({
- workspaceId,
- userId: impersonatorUserId,
- });
-
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
- eventName: 'server.impersonation.login_token_exchanged',
- message: 'Impersonation token exchanged',
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_success`,
+ message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
+
+ return {
+ workspaceId: workspace.id,
+ impersonatorUserWorkspaceId: impersonatorUserWorkspace.id,
+ impersonatedUserWorkspaceId: toImpersonateUserWorkspace.id,
+ impersonatorUserId: impersonatorUserWorkspace.user.id,
+ impersonatedUserId: toImpersonateUserWorkspace.user.id,
+ };
}
@Mutation(() => AuthorizeApp)
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
index b52fa53508..fb2aa44847 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
import { type Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
+import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import {
AuthException,
AuthExceptionCode,
@@ -12,21 +13,21 @@ import {
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
+import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
+import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { type ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
+import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
+import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
-import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
-import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
-import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
-import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
-import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
-import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthService } from './auth.service';
@@ -143,6 +144,10 @@ describe('AuthService', () => {
}),
},
},
+ {
+ provide: AuditService,
+ useValue: {},
+ },
],
}).compile();
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
index 4fece60d0c..2a14f0ba80 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import crypto from 'node:crypto';
+import crypto, { randomUUID } from 'node:crypto';
-import { t, msg } from '@lingui/core/macro';
+import { msg, t } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -18,6 +18,7 @@ import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
+import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import {
AuthException,
AuthExceptionCode,
@@ -87,6 +88,7 @@ export class AuthService {
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository,
private readonly i18nService: I18nService,
+ private readonly auditService: AuditService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -310,6 +312,65 @@ export class AuthService {
};
}
+ async generateImpersonationAccessTokenAndRefreshToken({
+ workspaceId,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
+ impersonatorUserId,
+ impersonatedUserId,
+ }: {
+ workspaceId: string;
+ impersonatorUserWorkspaceId: string;
+ impersonatedUserWorkspaceId: string;
+ impersonatorUserId: string;
+ impersonatedUserId: string;
+ }): Promise {
+ const correlationId = randomUUID();
+
+ const analytics = this.auditService.createContext({
+ workspaceId,
+ userId: impersonatorUserId,
+ });
+
+ await analytics.insertWorkspaceEvent('Monitoring', {
+ eventName: 'workspace.impersonation.attempted',
+ message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
+ });
+
+ const accessToken = await this.accessTokenService.generateAccessToken({
+ userId: impersonatedUserId,
+ workspaceId,
+ authProvider: AuthProviderEnum.Impersonation,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
+ });
+ const refreshToken = await this.refreshTokenService.generateRefreshToken(
+ {
+ userId: impersonatedUserId,
+ workspaceId,
+ authProvider: AuthProviderEnum.Impersonation,
+ targetedTokenType: JwtTokenTypeEnum.ACCESS,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
+ },
+ true,
+ );
+
+ await analytics.insertWorkspaceEvent('Monitoring', {
+ eventName: 'workspace.impersonation.issued',
+ message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
+ });
+
+ return {
+ tokens: {
+ accessOrWorkspaceAgnosticToken: accessToken,
+ refreshToken,
+ },
+ };
+ }
+
async countAvailableWorkspacesByEmail(email: string): Promise {
return Object.values(
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
index 952fceca30..632071cc72 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
@@ -1,3 +1,5 @@
+import { randomUUID } from 'crypto';
+
import {
AuthException,
AuthExceptionCode,
@@ -7,6 +9,15 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
+jest.mock('twenty-shared/utils', () => ({
+ ...jest.requireActual('twenty-shared/utils'),
+ assertIsDefinedOrThrow: jest.fn((value, error) => {
+ if (value === null || value === undefined) {
+ throw error;
+ }
+ }),
+}));
+
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
@@ -14,6 +25,7 @@ describe('JwtAuthStrategy', () => {
let userRepository: any;
let apiKeyRepository: any;
let jwtWrapperService: any;
+ let permissionsService: any;
const jwt = {
sub: 'sub-default',
@@ -40,6 +52,10 @@ describe('JwtAuthStrategy', () => {
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
+
+ permissionsService = {
+ userHasWorkspaceSettingPermission: jest.fn(),
+ };
});
afterEach(() => {
@@ -61,6 +77,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -90,6 +107,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -122,6 +140,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -154,6 +173,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
const result = await strategy.validate(payload as JwtPayload);
@@ -172,10 +192,15 @@ describe('JwtAuthStrategy', () => {
describe('ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+
const payload = {
- sub: 'sub-default',
+ sub: validUserId,
type: 'ACCESS',
- userWorkspaceId: 'userWorkspaceId',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -188,6 +213,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -202,10 +228,15 @@ describe('JwtAuthStrategy', () => {
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+
const payload = {
- sub: 'sub-default',
+ sub: validUserId,
type: 'ACCESS',
- userWorkspaceId: 'userWorkspaceId',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -220,6 +251,7 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -234,10 +266,15 @@ describe('JwtAuthStrategy', () => {
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+
const payload = {
- sub: 'sub-default',
+ sub: validUserId,
type: 'ACCESS',
- userWorkspaceId: 'userWorkspaceId',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
@@ -245,7 +282,9 @@ describe('JwtAuthStrategy', () => {
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue({
- id: 'userWorkspaceId',
+ id: validUserWorkspaceId,
+ user: { id: validUserId, lastName: 'lastNameDefault' },
+ workspace: { id: validWorkspaceId },
});
strategy = new JwtAuthStrategy(
@@ -254,12 +293,604 @@ describe('JwtAuthStrategy', () => {
userRepository,
userWorkspaceRepository,
apiKeyRepository,
+ permissionsService,
);
const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault');
- expect(user.userWorkspaceId).toBe('userWorkspaceId');
+ expect(user.userWorkspaceId).toBe(validUserWorkspaceId);
+ });
+ });
+
+ describe('Impersonation validation', () => {
+ it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ // Missing impersonatorUserWorkspaceId
+ };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: { id: validUserId, lastName: 'lastNameDefault' },
+ workspace: { id: validWorkspaceId },
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+
+ userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Invalid or missing user workspace ID in impersonation token',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw AuthException if impersonation token has missing impersonatedUserWorkspaceId', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ // Missing impersonatedUserWorkspaceId
+ };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: { id: validUserId, lastName: 'lastNameDefault' },
+ workspace: { id: validWorkspaceId },
+ };
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+
+ userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Invalid or missing user workspace ID in impersonation token',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw AuthException if user tries to impersonate themselves', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: validUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId, // Same as impersonator
+ };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: { id: validUserId, lastName: 'lastNameDefault' },
+ workspace: { id: validWorkspaceId },
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
+ permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ true,
+ );
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'User cannot impersonate themselves',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw AuthException if impersonator user workspace not found', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = true;
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(null) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce({
+ // For impersonatedUserWorkspace lookup
+ id: validUserWorkspaceId,
+ user: { id: randomUUID() },
+ workspace: mockWorkspace,
+ });
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ ),
+ );
+ });
+
+ it('should throw AuthException if impersonated user workspace not found', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = true;
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(null); // For impersonatedUserWorkspace lookup
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ ),
+ );
+ });
+
+ it('should throw AuthException for server level impersonation without permission', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+ const differentWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = false; // Disabled
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: impersonatorUserWorkspaceId,
+ user: { id: randomUUID(), canImpersonate: false }, // No server level permission
+ workspace: { id: differentWorkspaceId }, // Different workspace
+ };
+
+ const mockImpersonatedUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: { id: randomUUID() },
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
+
+ permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ false,
+ );
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Server level impersonation not allowed',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw AuthException when no impersonation permissions are granted', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = false;
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: impersonatorUserWorkspaceId,
+ user: { id: randomUUID(), canImpersonate: false },
+ workspace: mockWorkspace, // Same workspace
+ };
+
+ const mockImpersonatedUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: { id: randomUUID() },
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
+
+ permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ false,
+ );
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Impersonation not allowed',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw AuthException when impersonatedUserWorkspaceId does not match userWorkspaceId', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+ const impersonatedUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId, // Different from userWorkspaceId
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = true;
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: impersonatorUserWorkspaceId,
+ user: { id: randomUUID(), canImpersonate: true },
+ workspace: mockWorkspace,
+ };
+
+ const mockImpersonatedUserWorkspace = {
+ id: impersonatedUserWorkspaceId,
+ user: { id: randomUUID() },
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
+
+ permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ true,
+ );
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
+ new AuthException(
+ 'Token user workspace ID does not match impersonated user workspace ID',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should successfully validate workspace level impersonation with permission', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = false; // Server level disabled
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: impersonatorUserWorkspaceId,
+ user: { id: randomUUID(), canImpersonate: false },
+ workspace: mockWorkspace, // Same workspace
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce(mockUserWorkspace); // For impersonatedUserWorkspace lookup (same as main)
+
+ permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ true,
+ );
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ const result = await strategy.validate(payload as JwtPayload);
+
+ expect(result.user?.lastName).toBe('lastNameDefault');
+ expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
+ expect(result.impersonationContext).toBeDefined();
+ expect(result.impersonationContext?.impersonatorUserWorkspaceId).toBe(
+ impersonatorUserWorkspaceId,
+ );
+ expect(result.impersonationContext?.impersonatedUserWorkspaceId).toBe(
+ validUserWorkspaceId,
+ );
+ });
+
+ it('should successfully validate server level impersonation with permission', async () => {
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+ const differentWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validUserId,
+ type: 'ACCESS',
+ userWorkspaceId: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new Workspace();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.allowImpersonation = true; // Server level enabled
+
+ const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
+
+ const mockImpersonatorUserWorkspace = {
+ id: impersonatorUserWorkspaceId,
+ user: { id: randomUUID(), canImpersonate: true }, // Server level permission
+ workspace: { id: differentWorkspaceId }, // Different workspace
+ };
+
+ const mockImpersonatedUserWorkspace = {
+ id: validUserWorkspaceId,
+ user: mockUser,
+ workspace: mockWorkspace,
+ };
+
+ workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userWorkspaceRepository.findOne
+ .mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatedUserWorkspace) // For impersonatedUserWorkspace lookup
+ .mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For access token lookup
+
+ strategy = new JwtAuthStrategy(
+ jwtWrapperService,
+ workspaceRepository,
+ userRepository,
+ userWorkspaceRepository,
+ apiKeyRepository,
+ permissionsService,
+ );
+
+ const result = await strategy.validate(payload as JwtPayload);
+
+ expect(result.user?.lastName).toBe('lastNameDefault');
+ expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
+ expect(result.impersonationContext).toBeDefined();
+ expect(result.impersonationContext?.impersonatorUserWorkspaceId).toBe(
+ impersonatorUserWorkspaceId,
+ );
+ expect(result.impersonationContext?.impersonatedUserWorkspaceId).toBe(
+ validUserWorkspaceId,
+ );
});
});
});
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 bb9443633e..82bed3123a 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
@@ -4,8 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { Strategy } from 'passport-jwt';
+import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
-import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
@@ -25,6 +25,8 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
@@ -37,6 +39,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly userWorkspaceRepository: Repository,
@InjectRepository(ApiKey)
private readonly apiKeyRepository: Repository,
+ private readonly permissionsService: PermissionsService,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@@ -107,17 +110,26 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
payload: AccessTokenJwtPayload,
): Promise {
let user: User | null = null;
+ let context: AuthContext = {};
+
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
- if (!workspace) {
+ if (!isDefined(workspace)) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
+ if (payload.isImpersonating === true) {
+ context.impersonationContext = await this.validateImpersonation(
+ payload,
+ workspace,
+ );
+ }
+
const userId = payload.sub ?? payload.userId;
if (!userId) {
@@ -139,9 +151,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
- where: {
- id: payload.userWorkspaceId,
- },
+ where: { id: payload.userWorkspaceId },
+ relations: ['user', 'workspace'],
});
assertIsDefinedOrThrow(
@@ -155,7 +166,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
),
);
- return {
+ context = {
+ ...context,
user,
workspace,
authProvider: payload.authProvider,
@@ -163,6 +175,100 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
userWorkspaceId: userWorkspace.id,
workspaceMemberId: payload.workspaceMemberId,
};
+
+ return context;
+ }
+
+ private async validateImpersonation(
+ payload: AccessTokenJwtPayload,
+ workspace: Workspace,
+ ) {
+ // Validate required impersonation fields
+ if (
+ !payload.impersonatorUserWorkspaceId ||
+ !payload.impersonatedUserWorkspaceId
+ ) {
+ throw new AuthException(
+ 'Invalid or missing user workspace ID in impersonation token',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ if (payload.impersonatedUserWorkspaceId !== payload.userWorkspaceId) {
+ throw new AuthException(
+ 'Token user workspace ID does not match impersonated user workspace ID',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ if (
+ payload.impersonatedUserWorkspaceId ===
+ payload.impersonatorUserWorkspaceId
+ ) {
+ throw new AuthException(
+ 'User cannot impersonate themselves',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ const impersonatorUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: { id: payload.impersonatorUserWorkspaceId },
+ relations: ['user', 'workspace'],
+ });
+
+ const impersonatedUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: { id: payload.impersonatedUserWorkspaceId },
+ relations: ['user', 'workspace'],
+ });
+
+ if (
+ !isDefined(impersonatorUserWorkspace) ||
+ !isDefined(impersonatedUserWorkspace)
+ ) {
+ throw new AuthException(
+ 'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ );
+ }
+
+ const isServerLevelImpersonation =
+ impersonatorUserWorkspace.workspace.id !==
+ impersonatedUserWorkspace.workspace.id;
+
+ const hasServerLevelImpersonatePermission =
+ impersonatorUserWorkspace.user.canImpersonate === true &&
+ workspace.allowImpersonation === true;
+
+ if (isServerLevelImpersonation && !hasServerLevelImpersonatePermission) {
+ throw new AuthException(
+ 'Server level impersonation not allowed',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ const hasWorkspaceLevelImpersonatePermission =
+ await this.permissionsService.userHasWorkspaceSettingPermission({
+ userWorkspaceId: impersonatorUserWorkspace.id,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId: impersonatedUserWorkspace.workspace.id,
+ });
+
+ if (
+ !hasWorkspaceLevelImpersonatePermission &&
+ !hasServerLevelImpersonatePermission
+ ) {
+ throw new AuthException(
+ 'Impersonation not allowed',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ return {
+ impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId,
+ };
}
private async validateWorkspaceAgnosticToken(
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts
index 314ff0d77f..7373a40859 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts
@@ -1,6 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
+import { randomUUID } from 'crypto';
+
import { type Request } from 'express';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
@@ -104,8 +106,8 @@ describe('AccessTokenService', () => {
describe('generateAccessToken', () => {
it('should generate an access token successfully', async () => {
- const userId = 'user-id';
- const workspaceId = 'workspace-id';
+ const userId = randomUUID();
+ const workspaceId = randomUUID();
const mockUser = {
id: userId,
};
@@ -113,8 +115,8 @@ describe('AccessTokenService', () => {
activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId,
};
- const mockUserWorkspace = { id: 'userWorkspaceId' };
- const mockWorkspaceMember = { id: 'workspace-member-id' };
+ const mockUserWorkspace = { id: randomUUID() };
+ const mockWorkspaceMember = { id: randomUUID() };
const mockToken = 'mock-token';
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
@@ -152,6 +154,66 @@ describe('AccessTokenService', () => {
);
});
+ it('embeds impersonation claims when provided', async () => {
+ const userId = randomUUID();
+ const workspaceId = randomUUID();
+ const impersonatorUserWorkspaceId = randomUUID();
+ const impersonatedUserWorkspaceId = randomUUID();
+ const mockUser = { id: userId } as User;
+ const mockWorkspace = {
+ activationStatus: WorkspaceActivationStatus.ACTIVE,
+ id: workspaceId,
+ } as Workspace;
+ const mockUserWorkspace = {
+ id: impersonatedUserWorkspaceId,
+ } as UserWorkspace;
+ const mockWorkspaceMember = { id: randomUUID() };
+ const mockToken = 'mock-token';
+
+ jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
+ jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
+ jest
+ .spyOn(workspaceRepository, 'findOne')
+ .mockResolvedValue(mockWorkspace as Workspace);
+ jest
+ .spyOn(userWorkspaceRepository, 'findOne')
+ .mockResolvedValueOnce(mockUserWorkspace as UserWorkspace)
+ .mockResolvedValueOnce({
+ id: impersonatorUserWorkspaceId,
+ workspaceId,
+ } as UserWorkspace)
+ .mockResolvedValueOnce({
+ id: impersonatedUserWorkspaceId,
+ workspaceId,
+ } as UserWorkspace);
+ jest
+ .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
+ .mockResolvedValue({
+ findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
+ } as any);
+ const signSpy = jest
+ .spyOn(jwtWrapperService, 'sign')
+ .mockReturnValue(mockToken);
+
+ await service.generateAccessToken({
+ userId,
+ workspaceId,
+ authProvider: AuthProviderEnum.Impersonation,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: impersonatedUserWorkspaceId,
+ });
+
+ expect(signSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: impersonatedUserWorkspaceId,
+ }),
+ expect.any(Object),
+ );
+ });
+
it('should throw an error if user is not found', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts
index 9814057529..a0c23441c4 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts
@@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import { type Request } from 'express';
import ms from 'ms';
+import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
-import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import {
AuthException,
@@ -22,13 +22,13 @@ import {
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { UserWorkspaceNotFoundDefaultError } from 'src/engine/core-modules/user-workspace/user-workspace.exception';
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
-import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
-import { UserWorkspaceNotFoundDefaultError } from 'src/engine/core-modules/user-workspace/user-workspace.exception';
@Injectable()
export class AccessTokenService {
@@ -49,6 +49,9 @@ export class AccessTokenService {
userId,
workspaceId,
authProvider,
+ isImpersonating,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
@@ -106,6 +109,11 @@ export class AccessTokenService {
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
+ const payloadImpersonatorUserWorkspaceId =
+ isImpersonating === true ? impersonatorUserWorkspaceId : undefined;
+ const payloadOriginalUserWorkspaceId =
+ isImpersonating === true ? impersonatedUserWorkspaceId : undefined;
+
const jwtPayload: AccessTokenJwtPayload = {
sub: user.id,
userId: user.id,
@@ -114,6 +122,9 @@ export class AccessTokenService {
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.ACCESS,
authProvider,
+ isImpersonating: isImpersonating === true,
+ impersonatorUserWorkspaceId: payloadImpersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
};
return {
@@ -133,25 +144,9 @@ export class AccessTokenService {
const decoded = this.jwtWrapperService.decode(token);
- const {
- user,
- apiKey,
- workspace,
- workspaceMemberId,
- userWorkspace,
- userWorkspaceId,
- authProvider,
- } = await this.jwtStrategy.validate(decoded);
+ const context = await this.jwtStrategy.validate(decoded);
- return {
- user,
- apiKey,
- workspace,
- userWorkspace,
- workspaceMemberId,
- userWorkspaceId,
- authProvider,
- };
+ return context;
}
async validateTokenByRequest(request: Request): Promise {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts
index ae2ce35bb2..bb3dda1a8b 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts
@@ -92,7 +92,7 @@ describe('LoginTokenService', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
- const impersonatorUserId = 'impersonator-id';
+ const impersonatorUserWorkspaceId = 'impersonator-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
@@ -104,7 +104,7 @@ describe('LoginTokenService', () => {
email,
workspaceId,
AuthProviderEnum.Impersonation,
- { impersonatorUserId },
+ { impersonatorUserWorkspaceId },
);
expect(result).toEqual({
@@ -121,7 +121,7 @@ describe('LoginTokenService', () => {
workspaceId,
type: 'LOGIN',
authProvider: AuthProviderEnum.Impersonation,
- impersonatorUserId,
+ impersonatorUserWorkspaceId,
},
{ secret: mockSecret, expiresIn: expect.any(String) },
);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts
index 06dc8d9779..0025b34ce3 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts
@@ -23,14 +23,14 @@ export class LoginTokenService {
email: string,
workspaceId: string,
authProvider: AuthProviderEnum,
- options?: { impersonatorUserId?: string },
+ options?: { impersonatorUserWorkspaceId?: string },
): Promise {
const jwtPayload: LoginTokenJwtPayload = {
type: JwtTokenTypeEnum.LOGIN,
sub: email,
workspaceId,
authProvider,
- impersonatorUserId: options?.impersonatorUserId,
+ impersonatorUserWorkspaceId: options?.impersonatorUserWorkspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts
index c125aeaff6..09faaedaa0 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts
@@ -3,12 +3,15 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
-import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
+import {
+ AppToken,
+ AppTokenType,
+} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
+import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
-import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { RefreshTokenService } from './refresh-token.service';
@@ -73,14 +76,14 @@ describe('RefreshTokenService', () => {
id: 'token-id',
workspaceId: 'workspace-id',
revokedAt: null,
- };
- const mockUser: Partial = {
+ } as AppToken;
+ const mockUser = {
id: 'some-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
defaultAvatarUrl: '',
- };
+ } as User;
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
@@ -88,8 +91,8 @@ describe('RefreshTokenService', () => {
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload);
jest
.spyOn(appTokenRepository, 'findOneBy')
- .mockResolvedValue(mockAppToken as AppToken);
- jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
+ .mockResolvedValue(mockAppToken);
+ jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
const result = await service.verifyRefreshToken(mockToken);
@@ -173,4 +176,48 @@ describe('RefreshTokenService', () => {
).rejects.toThrow(AuthException);
});
});
+
+ it('returns impersonation claims from verified refresh token', async () => {
+ const refreshToken = 'rtok';
+ const userId = 'user-id';
+ const tokenId = 'token-id';
+
+ (jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (jwtWrapperService.decode as jest.Mock).mockReturnValue({
+ sub: userId,
+ jti: tokenId,
+ type: 'REFRESH',
+ targetedTokenType: 'ACCESS',
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: 'uw-imp',
+ impersonatedUserWorkspaceId: 'uw-orig',
+ });
+
+ const token = { id: tokenId, type: AppTokenType.RefreshToken } as AppToken;
+
+ jest.spyOn(appTokenRepository, 'findOneBy').mockResolvedValue(token);
+
+ const user = { id: userId } as User;
+
+ jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
+
+ const out = await service.verifyRefreshToken(refreshToken);
+
+ expect(out.isImpersonating).toBe(true);
+ expect(out.impersonatorUserWorkspaceId).toBe('uw-imp');
+ expect(out.impersonatedUserWorkspaceId).toBe('uw-orig');
+ });
+
+ it('throws on malformed refresh token', async () => {
+ (jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (jwtWrapperService.decode as jest.Mock).mockReturnValue({});
+
+ await expect(service.verifyRefreshToken('rtok')).rejects.toThrow(
+ AuthException,
+ );
+ });
});
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts
index 679b2d518e..36f375daec 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts
@@ -14,13 +14,13 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
-import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
-import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
-import { User } from 'src/engine/core-modules/user/user.entity';
import {
type RefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
+import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class RefreshTokenService {
@@ -103,17 +103,23 @@ export class RefreshTokenService {
token,
authProvider: jwtPayload.authProvider,
targetedTokenType: jwtPayload.targetedTokenType,
+ isImpersonating: jwtPayload.isImpersonating,
+ impersonatorUserWorkspaceId: jwtPayload.impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId: jwtPayload.impersonatedUserWorkspaceId,
};
}
async generateRefreshToken(
payload: Omit,
+ isImpersonationToken: boolean = false,
): Promise {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.REFRESH,
payload.workspaceId ?? payload.userId,
);
- const expiresIn = this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
+ const expiresIn = isImpersonationToken
+ ? '1d'
+ : this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts
index 16c03048ec..1f9ed38660 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts
@@ -80,15 +80,16 @@ describe('RenewTokenService', () => {
const mockAppToken: Partial = {
id: mockTokenId,
workspaceId: mockWorkspaceId,
- user: mockUser,
- userId: mockUser.id,
- };
+ } as AppToken;
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
user: mockUser,
token: mockAppToken as AppToken,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
+ isImpersonating: false,
+ impersonatorUserWorkspaceId: undefined,
+ impersonatedUserWorkspaceId: undefined,
});
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
jest
@@ -112,17 +113,75 @@ describe('RenewTokenService', () => {
{ id: mockTokenId },
{ revokedAt: expect.any(Date) },
);
- expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({
- userId: mockUser.id,
+ expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: mockUser.id,
+ workspaceId: mockWorkspaceId,
+ authProvider: AuthProviderEnum.Password,
+ }),
+ );
+ expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authProvider: AuthProviderEnum.Password,
+ targetedTokenType: JwtTokenTypeEnum.ACCESS,
+ userId: mockUser.id,
+ workspaceId: mockWorkspaceId,
+ }),
+ );
+ });
+
+ it('should propagate impersonation claims when present', async () => {
+ const mockRefreshToken = 'valid-refresh-token';
+ const mockUser = { id: 'user-id' } as User;
+ const mockWorkspaceId = 'workspace-id';
+ const mockTokenId = 'token-id';
+ const mockAccessToken = {
+ token: 'new-access-token',
+ expiresAt: new Date(),
+ };
+ const mockNewRefreshToken = {
+ token: 'new-refresh-token',
+ expiresAt: new Date(),
+ targetedTokenType: JwtTokenTypeEnum.ACCESS,
+ };
+ const mockAppToken = {
+ id: mockTokenId,
workspaceId: mockWorkspaceId,
- authProvider: AuthProviderEnum.Password,
- });
- expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({
+ } as AppToken;
+
+ jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
+ user: mockUser,
+ token: mockAppToken as AppToken,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
- userId: mockUser.id,
- workspaceId: mockWorkspaceId,
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: 'uw-imp',
+ impersonatedUserWorkspaceId: 'uw-orig',
});
+ jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
+ const accessSpy = jest
+ .spyOn(accessTokenService, 'generateAccessToken')
+ .mockResolvedValue(mockAccessToken);
+ const refreshSpy = jest
+ .spyOn(refreshTokenService, 'generateRefreshToken')
+ .mockResolvedValue(mockNewRefreshToken);
+
+ await service.generateTokensFromRefreshToken(mockRefreshToken);
+
+ expect(accessSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: 'uw-imp',
+ impersonatedUserWorkspaceId: 'uw-orig',
+ }),
+ );
+ expect(refreshSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isImpersonating: true,
+ impersonatorUserWorkspaceId: 'uw-imp',
+ impersonatedUserWorkspaceId: 'uw-orig',
+ }),
+ );
});
it('should throw an error if refresh token is not provided', async () => {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts
index 4e7fce9b71..35184c6d57 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts
@@ -42,6 +42,9 @@ export class RenewTokenService {
token: { id, workspaceId },
authProvider,
targetedTokenType: targetedTokenTypeFromPayload,
+ isImpersonating,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
} = await this.refreshTokenService.verifyRefreshToken(token);
// Revoke old refresh token
@@ -58,8 +61,6 @@ export class RenewTokenService {
const targetedTokenType =
targetedTokenTypeFromPayload ?? JwtTokenTypeEnum.ACCESS;
- // Support legacy tokens where authProvider might be undefined
- // TODO: remove in November 2025
const resolvedAuthProvider = authProvider ?? AuthProviderEnum.Password;
const accessToken =
@@ -75,6 +76,9 @@ export class RenewTokenService {
userId: user.id,
workspaceId,
authProvider: resolvedAuthProvider,
+ isImpersonating,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
});
const refreshToken = await this.refreshTokenService.generateRefreshToken({
@@ -82,6 +86,9 @@ export class RenewTokenService {
workspaceId,
authProvider: resolvedAuthProvider,
targetedTokenType,
+ isImpersonating,
+ impersonatorUserWorkspaceId,
+ impersonatedUserWorkspaceId,
});
return {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
index fdc4b0d102..4ab0b9ae62 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
@@ -15,6 +15,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
@@ -28,6 +29,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
]),
TypeORMModule,
DataSourceModule,
+ PermissionsModule,
],
providers: [
RenewTokenService,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts
index 90c439a555..425b26309c 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts
@@ -12,6 +12,10 @@ export type AuthContext = {
userWorkspaceId?: string;
userWorkspace?: UserWorkspace;
authProvider?: AuthProviderEnum;
+ impersonationContext?: {
+ impersonatorUserWorkspaceId?: string;
+ impersonatedUserWorkspaceId?: string;
+ };
};
export enum JwtTokenTypeEnum {
@@ -44,7 +48,7 @@ export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
authProvider: AuthProviderEnum;
- impersonatorUserId?: string;
+ impersonatorUserWorkspaceId?: string;
};
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
@@ -61,6 +65,9 @@ export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
authProvider?: AuthProviderEnum;
targetedTokenType: JwtTokenTypeEnum;
+ isImpersonating?: boolean;
+ impersonatorUserWorkspaceId?: string;
+ impersonatedUserWorkspaceId?: string;
};
export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & {
@@ -83,6 +90,9 @@ export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
workspaceMemberId?: string;
userWorkspaceId: string;
authProvider: AuthProviderEnum;
+ isImpersonating?: boolean;
+ impersonatorUserWorkspaceId?: string;
+ impersonatedUserWorkspaceId?: string;
};
export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
index 19a0267f68..38eeead330 100644
--- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
@@ -17,6 +17,8 @@ import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-
import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module';
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
import { captchaModuleFactory } from 'src/engine/core-modules/captcha/captcha.module-factory';
+import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
+import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
@@ -26,6 +28,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { GeoMapModule } from 'src/engine/core-modules/geo-map/geo-map-module';
import { HealthModule } from 'src/engine/core-modules/health/health.module';
import { ImapSmtpCaldavModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module';
+import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module';
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
import { LoggerModule } from 'src/engine/core-modules/logger/logger.module';
import { loggerModuleFactory } from 'src/engine/core-modules/logger/logger.module-factory';
@@ -35,6 +38,7 @@ import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timel
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
import { PageLayoutModule } from 'src/engine/core-modules/page-layout/page-layout.module';
import { PostgresCredentialsModule } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.module';
+import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { SearchModule } from 'src/engine/core-modules/search/search.module';
@@ -53,9 +57,6 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
-import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
-import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
-import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -130,6 +131,7 @@ import { FileModule } from './file/file.module';
WebhookModule,
CoreViewModule,
PageLayoutModule,
+ ImpersonationModule,
],
exports: [
AuditModule,
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
new file mode 100644
index 0000000000..b606d5b5c5
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts
@@ -0,0 +1,357 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
+import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
+import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { User } from 'src/engine/core-modules/user/user.entity';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+
+const UserWorkspaceFindOneMock = jest.fn();
+const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
+const PermissionsServiceUserHasWorkspaceSettingPermissionMock = jest.fn();
+
+describe('ImpersonationService', () => {
+ let service: ImpersonationService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ImpersonationService,
+ {
+ provide: getRepositoryToken(User),
+ useValue: {
+ findOne: jest.fn(), // Not used but required by constructor
+ },
+ },
+ {
+ provide: getRepositoryToken(UserWorkspace),
+ useValue: {
+ findOne: UserWorkspaceFindOneMock,
+ },
+ },
+ {
+ provide: LoginTokenService,
+ useValue: {
+ generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
+ },
+ },
+ {
+ provide: DomainManagerService,
+ useValue: {
+ getWorkspaceUrls: jest.fn().mockReturnValue({
+ customUrl: undefined,
+ subdomainUrl: 'https://twenty.twenty.com',
+ }),
+ },
+ },
+ {
+ provide: AuditService,
+ useValue: {
+ createContext: jest.fn().mockReturnValue({
+ insertWorkspaceEvent: jest.fn(),
+ }),
+ },
+ },
+ {
+ provide: PermissionsService,
+ useValue: {
+ userHasWorkspaceSettingPermission:
+ PermissionsServiceUserHasWorkspaceSettingPermissionMock,
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(ImpersonationService);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should be defined', async () => {
+ expect(service).toBeDefined();
+ });
+
+ it('should impersonate a user and return workspace and loginToken on success', async () => {
+ const mockToImpersonateUserWorkspace = {
+ userId: 'target-user-id',
+ workspaceId: 'workspace-id',
+ user: {
+ id: 'target-user-id',
+ email: 'target@example.com',
+ },
+ workspace: {
+ id: 'workspace-id',
+ allowImpersonation: true,
+ subdomain: 'example-subdomain',
+ },
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: 'impersonator-user-workspace-id',
+ userId: 'impersonator-user-id',
+ workspaceId: 'workspace-id',
+ user: {
+ id: 'impersonator-user-id',
+ email: 'impersonator@example.com',
+ },
+ workspace: {
+ id: 'workspace-id',
+ allowImpersonation: true,
+ subdomain: 'example-subdomain',
+ },
+ };
+
+ // Mock first call for target user workspace
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockToImpersonateUserWorkspace,
+ );
+ // Mock second call for impersonator user workspace
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockImpersonatorUserWorkspace,
+ );
+
+ // Mock workspace-level permission check to return true
+ 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(UserWorkspaceFindOneMock).toHaveBeenCalledTimes(2);
+
+ expect(UserWorkspaceFindOneMock).toHaveBeenNthCalledWith(1, {
+ where: {
+ userId: 'target-user-id',
+ workspaceId: 'workspace-id',
+ },
+ relations: ['user', 'workspace'],
+ });
+
+ expect(UserWorkspaceFindOneMock).toHaveBeenNthCalledWith(2, {
+ where: {
+ id: 'impersonator-user-workspace-id',
+ },
+ relations: ['user', 'workspace'],
+ });
+
+ expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
+ 'target@example.com',
+ 'workspace-id',
+ 'impersonation',
+ { impersonatorUserWorkspaceId: '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),
+ },
+ });
+ });
+
+ it('should allow impersonation within the same workspace even when allowImpersonation is false', async () => {
+ const mockToImpersonateUserWorkspace = {
+ userId: 'target-user-id',
+ workspaceId: 'workspace-id',
+ user: { id: 'target-user-id', email: 'target@example.com' },
+ workspace: { id: 'workspace-id', allowImpersonation: false },
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: 'impersonator-user-workspace-id',
+ userId: 'impersonator-user-id',
+ workspaceId: 'workspace-id', // Same workspace ID
+ user: { id: 'impersonator-user-id', canImpersonate: false }, // Explicitly set to false
+ workspace: { id: 'workspace-id', allowImpersonation: false }, // Same workspace ID
+ };
+
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockToImpersonateUserWorkspace,
+ );
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockImpersonatorUserWorkspace,
+ );
+
+ // Mock workspace-level permission check to return true
+ PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockResolvedValueOnce(
+ true,
+ );
+
+ LoginTokenServiceGenerateLoginTokenMock.mockResolvedValueOnce({
+ token: 'mock-login-token',
+ expiresAt: new Date(),
+ });
+
+ // This should succeed because same-workspace impersonation doesn't check allowImpersonation
+ 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),
+ },
+ });
+ });
+
+ it('should throw an error when target user is not found', async () => {
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(null); // Target user not found
+ UserWorkspaceFindOneMock.mockResolvedValueOnce({
+ id: 'impersonator-user-workspace-id',
+ userId: 'impersonator-user-id',
+ workspaceId: 'workspace-id',
+ user: { id: 'impersonator-user-id' },
+ workspace: { id: 'workspace-id' },
+ });
+
+ await expect(
+ service.impersonate(
+ 'invalid-user-id',
+ 'workspace-id',
+ 'impersonator-user-workspace-id',
+ ),
+ ).rejects.toThrow(
+ new AuthException(
+ 'User not found in workspace or impersonation not enabled',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ ),
+ );
+ });
+
+ it('should throw an error when impersonator user workspace is not found', async () => {
+ UserWorkspaceFindOneMock.mockResolvedValueOnce({
+ userId: 'target-user-id',
+ workspaceId: 'workspace-id',
+ user: { id: 'target-user-id' },
+ workspace: { id: 'workspace-id' },
+ });
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(null); // Impersonator workspace not found
+
+ await expect(
+ service.impersonate(
+ 'target-user-id',
+ 'workspace-id',
+ 'invalid-impersonator-workspace-id',
+ ),
+ ).rejects.toThrow(
+ new AuthException(
+ 'User not found in workspace or impersonation not enabled',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ ),
+ );
+ });
+
+ it('should throw an error when impersonation is not enabled for the workspace', async () => {
+ const mockToImpersonateUserWorkspace = {
+ userId: 'target-user-id',
+ workspaceId: 'workspace-id',
+ user: { id: 'target-user-id', email: 'target@example.com' },
+ workspace: { id: 'workspace-id', allowImpersonation: false },
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: 'impersonator-user-workspace-id',
+ userId: 'impersonator-user-id',
+ workspaceId: 'other-workspace-id',
+ user: { id: 'impersonator-user-id' },
+ workspace: { id: 'other-workspace-id' },
+ };
+
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockToImpersonateUserWorkspace,
+ );
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockImpersonatorUserWorkspace,
+ );
+
+ await expect(
+ service.impersonate(
+ 'target-user-id',
+ 'workspace-id',
+ 'impersonator-user-workspace-id',
+ ),
+ ).rejects.toThrow(
+ new AuthException(
+ 'Impersonation not enabled for the impersonator user or the target workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+
+ it('should throw an error when impersonation is not enabled at server level for the user', async () => {
+ const mockToImpersonateUserWorkspace = {
+ userId: 'target-user-id',
+ workspaceId: 'target-workspace-id',
+ user: { id: 'target-user-id', email: 'target@example.com' },
+ workspace: { id: 'target-workspace-id', allowImpersonation: true },
+ };
+
+ const mockImpersonatorUserWorkspace = {
+ id: 'impersonator-user-workspace-id',
+ userId: 'impersonator-user-id',
+ workspaceId: 'impersonator-workspace-id',
+ user: { id: 'impersonator-user-id', canImpersonate: false },
+ workspace: { id: 'impersonator-workspace-id', allowImpersonation: true },
+ };
+
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockToImpersonateUserWorkspace,
+ );
+ UserWorkspaceFindOneMock.mockResolvedValueOnce(
+ mockImpersonatorUserWorkspace,
+ );
+
+ // Mock workspace-level permission check to return false
+ PermissionsServiceUserHasWorkspaceSettingPermissionMock.mockResolvedValueOnce(
+ false,
+ );
+
+ await expect(
+ service.impersonate(
+ 'target-user-id',
+ 'target-workspace-id',
+ 'impersonator-user-workspace-id',
+ ),
+ ).rejects.toThrow(
+ new AuthException(
+ 'Impersonation not enabled for the impersonator user or the target workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ ),
+ );
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts
new file mode 100644
index 0000000000..6534d0aefc
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts
@@ -0,0 +1,32 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
+import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
+import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
+import { ImpersonationResolver } from 'src/engine/core-modules/impersonation/impersonation.resolver';
+import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
+import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
+import { User } from 'src/engine/core-modules/user/user.entity';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
+import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
+import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
+
+@Module({
+ imports: [
+ AuthModule,
+ UserWorkspaceModule,
+ PermissionsModule,
+ RoleModule,
+ UserRoleModule,
+ AuditModule,
+ TypeOrmModule.forFeature([UserWorkspace, Workspace, User]),
+ DomainManagerModule,
+ PermissionsModule,
+ ],
+ providers: [ImpersonationService, ImpersonationResolver],
+ exports: [ImpersonationService],
+})
+export class ImpersonationModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts
new file mode 100644
index 0000000000..7358c9d622
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts
@@ -0,0 +1,47 @@
+import { UseGuards, UsePipes } from '@nestjs/common';
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+
+import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
+import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
+import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
+import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
+import { ImpersonatePermissionGuard } from 'src/engine/guards/impersonate-permission.guard';
+import { NoImpersonationGuard } from 'src/engine/guards/no-impersonation.guard';
+import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
+import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
+
+@Resolver()
+@UsePipes(ResolverValidationPipe)
+export class ImpersonationResolver {
+ constructor(private readonly impersonationService: ImpersonationService) {}
+
+ @UseGuards(
+ WorkspaceAuthGuard,
+ UserAuthGuard,
+ NoImpersonationGuard,
+ ImpersonatePermissionGuard,
+ )
+ @Mutation(() => ImpersonateOutput)
+ async impersonate(
+ @Args() { workspaceId, userId: toImpersonateUserId }: ImpersonateInput,
+ @AuthUserWorkspaceId() impersonatorUserWorkspaceId: string,
+ ): Promise {
+ if (!impersonatorUserWorkspaceId) {
+ throw new AuthException(
+ 'Impersonator user not found',
+ AuthExceptionCode.UNAUTHENTICATED,
+ );
+ }
+
+ return await this.impersonationService.impersonate(
+ toImpersonateUserId,
+ workspaceId,
+ impersonatorUserWorkspaceId,
+ );
+ }
+}
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
new file mode 100644
index 0000000000..ec54c0aa1f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts
@@ -0,0 +1,143 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { isDefined } from 'twenty-shared/utils';
+import { Repository } from 'typeorm';
+
+import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
+import { MONITORING_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
+import {
+ AuthException,
+ AuthExceptionCode,
+} from 'src/engine/core-modules/auth/auth.exception';
+import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
+import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
+import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
+import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+
+@Injectable()
+export class ImpersonationService {
+ constructor(
+ private readonly auditService: AuditService,
+ private readonly domainManagerService: DomainManagerService,
+ private readonly loginTokenService: LoginTokenService,
+ @InjectRepository(UserWorkspace)
+ private readonly userWorkspaceRepository: Repository,
+ private readonly permissionsService: PermissionsService,
+ ) {}
+
+ async impersonate(
+ toImpersonateUserId: string,
+ workspaceId: string,
+ impersonatorUserWorkspaceId: string,
+ ) {
+ const toImpersonateUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: {
+ userId: toImpersonateUserId,
+ workspaceId,
+ },
+ relations: ['user', 'workspace'],
+ });
+
+ const impersonatorUserWorkspace =
+ await this.userWorkspaceRepository.findOne({
+ where: { id: impersonatorUserWorkspaceId },
+ relations: ['user', 'workspace'],
+ });
+
+ if (
+ !isDefined(toImpersonateUserWorkspace) ||
+ !isDefined(impersonatorUserWorkspace)
+ ) {
+ throw new AuthException(
+ 'User not found in workspace or impersonation not enabled',
+ AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
+ );
+ }
+
+ const isServerLevelImpersonation =
+ toImpersonateUserWorkspace.workspace.id !==
+ impersonatorUserWorkspace.workspace.id;
+
+ const hasServerLevelImpersonatePermission =
+ impersonatorUserWorkspace.user.canImpersonate === true &&
+ impersonatorUserWorkspace.workspace.allowImpersonation === true;
+
+ if (isServerLevelImpersonation && !hasServerLevelImpersonatePermission) {
+ throw new AuthException(
+ 'Impersonation not enabled for the impersonator user or the target workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ const hasWorkspaceLevelImpersonatePermission =
+ await this.permissionsService.userHasWorkspaceSettingPermission({
+ userWorkspaceId: impersonatorUserWorkspace.id,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId: workspaceId,
+ });
+
+ if (
+ !hasWorkspaceLevelImpersonatePermission &&
+ !hasServerLevelImpersonatePermission
+ ) {
+ throw new AuthException(
+ 'Impersonation not enabled for this workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ );
+ }
+
+ const auditService = this.auditService.createContext({
+ workspaceId: impersonatorUserWorkspace.workspace.id,
+ userId: impersonatorUserWorkspace.user.id,
+ });
+
+ await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.attempt`,
+ message: `Impersonation attempt: targetUserId=${toImpersonateUserWorkspace.user.id}, workspaceId=${workspaceId}, impersonatorUserId=${impersonatorUserWorkspace.user.id}`,
+ });
+
+ try {
+ await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.login_token_attempt`,
+ message: `Impersonation token generation attempt for user ${toImpersonateUserWorkspace.user.id}`,
+ });
+
+ const loginToken = await this.loginTokenService.generateLoginToken(
+ toImpersonateUserWorkspace.user.email,
+ toImpersonateUserWorkspace.workspace.id,
+ AuthProviderEnum.Impersonation,
+ {
+ impersonatorUserWorkspaceId: impersonatorUserWorkspace.id,
+ },
+ );
+
+ await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.login_token_generated`,
+ message: `Impersonation token generated successfully for user ${toImpersonateUserWorkspace.user.id}`,
+ });
+
+ return {
+ workspace: {
+ id: toImpersonateUserWorkspace.workspace.id,
+ workspaceUrls: this.domainManagerService.getWorkspaceUrls(
+ toImpersonateUserWorkspace.workspace,
+ ),
+ },
+ loginToken,
+ };
+ } catch {
+ await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
+ eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.login_token_failed`,
+ message: `Impersonation token generation failed for targetUserId=${toImpersonateUserWorkspace.user.id}`,
+ });
+ throw new AuthException(
+ 'Impersonation failed',
+ AuthExceptionCode.INVALID_DATA,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/guards/__tests__/impersonate-guard.spec.ts b/packages/twenty-server/src/engine/guards/__tests__/impersonate-guard.spec.ts
index be7c15dc5b..b145c48255 100644
--- a/packages/twenty-server/src/engine/guards/__tests__/impersonate-guard.spec.ts
+++ b/packages/twenty-server/src/engine/guards/__tests__/impersonate-guard.spec.ts
@@ -1,10 +1,20 @@
import { type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
-import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
+import { ImpersonatePermissionGuard } from 'src/engine/guards/impersonate-permission.guard';
+import { type PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
describe('ImpersonateGuard', () => {
- const guard = new ImpersonateGuard();
+ let guard: ImpersonatePermissionGuard;
+ let mockPermissionsService: jest.Mocked;
+
+ beforeEach(() => {
+ mockPermissionsService = {
+ userHasWorkspaceSettingPermission: jest.fn(),
+ } as any;
+
+ guard = new ImpersonatePermissionGuard(mockPermissionsService);
+ });
it('should return true if user can impersonate', async () => {
const mockContext = {
@@ -13,6 +23,10 @@ describe('ImpersonateGuard', () => {
user: {
canImpersonate: true,
},
+ userWorkspaceId: 'user-workspace-id',
+ workspace: {
+ id: 'workspace-id',
+ },
},
})),
};
@@ -26,15 +40,91 @@ describe('ImpersonateGuard', () => {
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
+ expect(
+ mockPermissionsService.userHasWorkspaceSettingPermission,
+ ).not.toHaveBeenCalled();
});
- it('should return false if user cannot impersonate', async () => {
+ it('should return true if user has workspace permission to impersonate', async () => {
const mockContext = {
getContext: jest.fn(() => ({
req: {
user: {
canImpersonate: false,
},
+ userWorkspaceId: 'user-workspace-id',
+ workspace: {
+ id: 'workspace-id',
+ },
+ },
+ })),
+ };
+
+ mockPermissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ true,
+ );
+
+ jest
+ .spyOn(GqlExecutionContext, 'create')
+ .mockReturnValue(mockContext as any);
+
+ const mockExecutionContext = {} as ExecutionContext;
+
+ const result = await guard.canActivate(mockExecutionContext);
+
+ expect(result).toBe(true);
+ expect(
+ mockPermissionsService.userHasWorkspaceSettingPermission,
+ ).toHaveBeenCalledWith({
+ userWorkspaceId: 'user-workspace-id',
+ setting: 'IMPERSONATE',
+ workspaceId: 'workspace-id',
+ });
+ });
+
+ it('should throw permission denied exception when user cannot impersonate and has no workspace permission', async () => {
+ const mockContext = {
+ getContext: jest.fn(() => ({
+ req: {
+ user: {
+ canImpersonate: false,
+ },
+ userWorkspaceId: 'user-workspace-id',
+ workspace: {
+ id: 'workspace-id',
+ },
+ },
+ })),
+ };
+
+ mockPermissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
+ false,
+ );
+
+ jest
+ .spyOn(GqlExecutionContext, 'create')
+ .mockReturnValue(mockContext as any);
+
+ const mockExecutionContext = {} as ExecutionContext;
+
+ await expect(guard.canActivate(mockExecutionContext)).rejects.toMatchObject(
+ {
+ userFriendlyMessage:
+ 'You do not have permission to impersonate users. Please contact your workspace administrator for access.',
+ },
+ );
+ });
+
+ it('should throw permission denied exception when userWorkspaceId is not defined', async () => {
+ const mockContext = {
+ getContext: jest.fn(() => ({
+ req: {
+ user: {
+ canImpersonate: false,
+ },
+ workspace: {
+ id: 'workspace-id',
+ },
},
})),
};
@@ -45,8 +135,10 @@ describe('ImpersonateGuard', () => {
const mockExecutionContext = {} as ExecutionContext;
- const result = await guard.canActivate(mockExecutionContext);
-
- expect(result).toBe(false);
+ await expect(guard.canActivate(mockExecutionContext)).rejects.toMatchObject(
+ {
+ userFriendlyMessage: "Can't impersonate user via api key",
+ },
+ );
});
});
diff --git a/packages/twenty-server/src/engine/guards/impersonate-guard.ts b/packages/twenty-server/src/engine/guards/impersonate-guard.ts
deleted file mode 100644
index 17be00f32b..0000000000
--- a/packages/twenty-server/src/engine/guards/impersonate-guard.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { type CanActivate, type ExecutionContext } from '@nestjs/common';
-import { GqlExecutionContext } from '@nestjs/graphql';
-
-import { type Observable } from 'rxjs';
-
-export class ImpersonateGuard implements CanActivate {
- canActivate(
- context: ExecutionContext,
- ): boolean | Promise | Observable {
- const ctx = GqlExecutionContext.create(context);
- const request = ctx.getContext().req;
-
- return request.user.canImpersonate === true;
- }
-}
diff --git a/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts
new file mode 100644
index 0000000000..23dc3f11d3
--- /dev/null
+++ b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts
@@ -0,0 +1,58 @@
+import {
+ Injectable,
+ type CanActivate,
+ type ExecutionContext,
+} from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+
+import { isDefined } from 'class-validator';
+
+import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
+import {
+ PermissionsException,
+ PermissionsExceptionCode,
+ PermissionsExceptionMessage,
+} from 'src/engine/metadata-modules/permissions/permissions.exception';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+
+@Injectable()
+export class ImpersonatePermissionGuard implements CanActivate {
+ constructor(private readonly permissionsService: PermissionsService) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const ctx = GqlExecutionContext.create(context);
+ const request = ctx.getContext().req;
+ const userWorkspaceId = request.userWorkspaceId;
+ const workspaceId = request.workspace.id;
+
+ if (!isDefined(userWorkspaceId)) {
+ throw new PermissionsException(
+ PermissionsExceptionMessage.PERMISSION_DENIED,
+ PermissionsExceptionCode.PERMISSION_DENIED,
+ {
+ userFriendlyMessage: "Can't impersonate user via api key",
+ },
+ );
+ }
+
+ if (request.user.canImpersonate === true) return true;
+
+ const hasPermission =
+ await this.permissionsService.userHasWorkspaceSettingPermission({
+ userWorkspaceId,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId,
+ });
+
+ if (hasPermission === true) return true;
+
+ throw new PermissionsException(
+ PermissionsExceptionMessage.PERMISSION_DENIED,
+ PermissionsExceptionCode.PERMISSION_DENIED,
+ {
+ userFriendlyMessage:
+ 'You do not have permission to impersonate users. Please contact your workspace administrator for access.',
+ },
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/guards/no-impersonation.guard.ts b/packages/twenty-server/src/engine/guards/no-impersonation.guard.ts
new file mode 100644
index 0000000000..fd59f23144
--- /dev/null
+++ b/packages/twenty-server/src/engine/guards/no-impersonation.guard.ts
@@ -0,0 +1,33 @@
+import {
+ type CanActivate,
+ type ExecutionContext,
+ ForbiddenException,
+ Injectable,
+} from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+
+@Injectable()
+export class NoImpersonationGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ const ctx = GqlExecutionContext.create(context);
+ const request = ctx.getContext().req as {
+ impersonationContext?: {
+ impersonatorUserWorkspaceId?: string;
+ impersonatedUserWorkspaceId?: string;
+ };
+ };
+
+ const isCurrentlyImpersonating = Boolean(
+ request?.impersonationContext?.impersonatorUserWorkspaceId &&
+ request?.impersonationContext?.impersonatedUserWorkspaceId,
+ );
+
+ if (isCurrentlyImpersonating) {
+ throw new ForbiddenException(
+ "Can't access this resource while impersonating",
+ );
+ }
+
+ return true;
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
index 9996bf60bd..27773a085d 100644
--- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
@@ -8,6 +8,7 @@ export enum PermissionFlagType {
ADMIN_PANEL = 'ADMIN_PANEL',
SECURITY = 'SECURITY',
WORKFLOWS = 'WORKFLOWS',
+ IMPERSONATE = 'IMPERSONATE',
// Tool permissions
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
index 5cc5b7ca33..b1be6d5aa0 100644
--- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
@@ -103,6 +103,7 @@ export class PermissionsService {
[PermissionFlagType.SEND_EMAIL_TOOL]: false,
[PermissionFlagType.IMPORT_CSV]: false,
[PermissionFlagType.EXPORT_CSV]: false,
+ [PermissionFlagType.IMPERSONATE]: false,
},
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;
diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts
index 0ec34daf1d..5e76ade0fc 100644
--- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts
+++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts
@@ -163,6 +163,7 @@ export class MiddlewareService {
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
request.authProvider = data.authProvider;
+ request.impersonationContext = data.impersonationContext;
request.locale =
data.userWorkspace?.locale ??
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts
index 28272621e4..1f2b49012b 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts
@@ -458,7 +458,7 @@ describe('Relative Date Filter Utils', () => {
// Different days should not match
expect(
evaluateRelativeDateFilter({
- dateToCheck: new Date('2024-01-14T20:00:00Z'),
+ dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..0c50c645b6
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts
@@ -0,0 +1,48 @@
+import request from 'supertest';
+
+const client = request(`http://localhost:${APP_PORT}`);
+
+describe('peopleResolver (e2e)', () => {
+ it('should find many people', () => {
+ const queryData = {
+ query: `
+ query people {
+ people {
+ edges {
+ node {
+ jobTitle
+ city
+ avatarUrl
+ position
+ searchVector
+ id
+ createdAt
+ updatedAt
+ deletedAt
+ companyId
+ intro
+ workPreference
+ performanceRating
+ }
+ }
+ }
+ }
+ `,
+ };
+
+ return client
+ .post('/graphql')
+ .set(
+ 'Authorization',
+ `Bearer ${APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN}`,
+ )
+ .send(queryData)
+ .expect(200)
+ .expect((res) => {
+ expect(res.body.errors).toBeDefined();
+ expect(res.body.errors[0].message).toContain(
+ 'Impersonation not allowed',
+ );
+ });
+ });
+});
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index b0cee9d18f..40ab2783da 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -294,6 +294,7 @@ export {
IconSortDescending,
IconSortZA,
IconSparkles,
+ IconSpy,
IconSql,
IconSquare,
IconSquareKey,
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 707cdb2cd5..52ca2b989f 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -356,6 +356,7 @@ export {
IconSortDescending,
IconSortZA,
IconSparkles,
+ IconSpy,
IconSql,
IconSquare,
IconSquareKey,