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 && ( + { + onImpersonate(workspaceMember); + closeDropdown(dropdownId); + }} + /> + )} + { + onDelete(workspaceMember.id); + closeDropdown(dropdownId); + }} + /> + + + } + /> + ); +}; 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,