feat: Added workspace-level impersonation module and functionality (#14360)
## Description - This PR is focused on issue https://github.com/twentyhq/core-team-issues/issues/1421 - Added workspace-level impersonation apart from current server-level-impersonation - Introduced WorkspaceImpersonationModule, including service and resolver for user impersonation within workspaces. - Enhanced AdminPanelService and AdminPanelResolver to support impersonation logic, including authentication checks and token generation. - added Member Management Interface in settings/members table - added GraphQL mutation for workspace-level impersonation ## Visual Proof <img width="919" height="444" alt="Screenshot 2025-09-09 at 1 29 22 AM" src="https://github.com/user-attachments/assets/a361c6eb-03de-48db-b68a-7d3a489fafde" /> https://github.com/user-attachments/assets/454bc1aa-8b8f-4de1-9304-7c147da71dd4 ## Impersontion Cookie <img width="1792" height="297" alt="Screenshot 2025-09-11 at 9 44 06 PM" src="https://github.com/user-attachments/assets/a8a37d5e-d38d-4472-94a7-4e4b36fba14e" /> <img width="1235" height="1041" alt="Screenshot 2025-09-19 at 1 24 09 AM" src="https://github.com/user-attachments/assets/875e8f9c-e0cb-404b-8291-8fadc6c2a163" /> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
clickableComponent={
|
||||
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
{canImpersonate && (
|
||||
<MenuItem
|
||||
LeftIcon={IconSpy}
|
||||
text={t`Impersonate`}
|
||||
onClick={() => {
|
||||
onImpersonate(workspaceMember);
|
||||
closeDropdown(dropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text={t`Delete`}
|
||||
onClick={() => {
|
||||
onDelete(workspaceMember.id);
|
||||
closeDropdown(dropdownId);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+7
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
<TableCell align="right">
|
||||
{currentWorkspaceMember?.id !== workspaceMember.id && (
|
||||
<StyledButtonContainer>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
<ManageMembersDropdownMenu
|
||||
dropdownId={`workspace-member-actions-${workspaceMember.id}`}
|
||||
workspaceMember={workspaceMember}
|
||||
onImpersonate={handleImpersonate}
|
||||
onDelete={(id) => {
|
||||
setWorkspaceMemberToDelete(id);
|
||||
openModal(WORKSPACE_MEMBER_DELETION_MODAL_ID);
|
||||
setWorkspaceMemberToDelete(workspaceMember.id);
|
||||
}}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
Icon={IconTrash}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
)}
|
||||
|
||||
+2
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
-83
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<ImpersonateOutput> {
|
||||
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,
|
||||
|
||||
@@ -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<User>,
|
||||
) {}
|
||||
|
||||
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<UserLookup> {
|
||||
const isEmail = userIdentifier.includes('@');
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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<User>,
|
||||
@InjectRepository(UserWorkspace)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
@InjectRepository(AppToken)
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<AppToken>,
|
||||
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<AuthTokens> {
|
||||
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<number> {
|
||||
return Object.values(
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
|
||||
|
||||
+639
-8
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+112
-6
@@ -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<UserWorkspace>,
|
||||
@InjectRepository(ApiKey)
|
||||
private readonly apiKeyRepository: Repository<ApiKey>,
|
||||
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<AuthContext> {
|
||||
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(
|
||||
|
||||
+66
-4
@@ -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);
|
||||
|
||||
+16
-21
@@ -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<AccessTokenJwtPayload>(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<AuthContext> {
|
||||
|
||||
+3
-3
@@ -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) },
|
||||
);
|
||||
|
||||
+2
-2
@@ -23,14 +23,14 @@ export class LoginTokenService {
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
authProvider: AuthProviderEnum,
|
||||
options?: { impersonatorUserId?: string },
|
||||
options?: { impersonatorUserWorkspaceId?: string },
|
||||
): Promise<AuthToken> {
|
||||
const jwtPayload: LoginTokenJwtPayload = {
|
||||
type: JwtTokenTypeEnum.LOGIN,
|
||||
sub: email,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
impersonatorUserId: options?.impersonatorUserId,
|
||||
impersonatorUserWorkspaceId: options?.impersonatorUserWorkspaceId,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
|
||||
+54
-7
@@ -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<User> = {
|
||||
} 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-4
@@ -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<RefreshTokenJwtPayload, 'type' | 'sub' | 'jti'>,
|
||||
isImpersonationToken: boolean = false,
|
||||
): Promise<AuthToken> {
|
||||
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(
|
||||
|
||||
+69
-10
@@ -80,15 +80,16 @@ describe('RenewTokenService', () => {
|
||||
const mockAppToken: Partial<AppToken> = {
|
||||
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 () => {
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+357
@@ -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>(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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
+47
@@ -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<ImpersonateOutput> {
|
||||
if (!impersonatorUserWorkspaceId) {
|
||||
throw new AuthException(
|
||||
'Impersonator user not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.impersonationService.impersonate(
|
||||
toImpersonateUserId,
|
||||
workspaceId,
|
||||
impersonatorUserWorkspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+143
@@ -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<UserWorkspace>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PermissionsService>;
|
||||
|
||||
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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> | Observable<boolean> {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const request = ctx.getContext().req;
|
||||
|
||||
return request.user.canImpersonate === true;
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+48
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,7 @@ export {
|
||||
IconSortDescending,
|
||||
IconSortZA,
|
||||
IconSparkles,
|
||||
IconSpy,
|
||||
IconSql,
|
||||
IconSquare,
|
||||
IconSquareKey,
|
||||
|
||||
@@ -356,6 +356,7 @@ export {
|
||||
IconSortDescending,
|
||||
IconSortZA,
|
||||
IconSparkles,
|
||||
IconSpy,
|
||||
IconSql,
|
||||
IconSquare,
|
||||
IconSquareKey,
|
||||
|
||||
Reference in New Issue
Block a user