Enforce 2FA for server-level impersonation (#14842)
Better for security
This commit is contained in:
+258
-1
@@ -1,6 +1,8 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import {
|
||||
AuthException,
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants';
|
||||
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';
|
||||
@@ -16,11 +20,19 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
|
||||
const UserWorkspaceFindOneMock = jest.fn();
|
||||
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
|
||||
const PermissionsServiceUserHasWorkspaceSettingPermissionMock = jest.fn();
|
||||
const TwentyConfigServiceGetMock = jest.fn();
|
||||
|
||||
describe('ImpersonationService', () => {
|
||||
let service: ImpersonationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'NODE_ENV') {
|
||||
return NodeEnvironment.PRODUCTION;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ImpersonationService,
|
||||
@@ -42,6 +54,12 @@ describe('ImpersonationService', () => {
|
||||
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: TwentyConfigServiceGetMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
@@ -108,6 +126,7 @@ describe('ImpersonationService', () => {
|
||||
allowImpersonation: true,
|
||||
subdomain: 'example-subdomain',
|
||||
},
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
// Mock first call for target user workspace
|
||||
@@ -149,7 +168,7 @@ describe('ImpersonationService', () => {
|
||||
where: {
|
||||
id: 'impersonator-user-workspace-id',
|
||||
},
|
||||
relations: ['user', 'workspace'],
|
||||
relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'],
|
||||
});
|
||||
|
||||
expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
|
||||
@@ -188,6 +207,7 @@ describe('ImpersonationService', () => {
|
||||
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
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
@@ -237,6 +257,7 @@ describe('ImpersonationService', () => {
|
||||
workspaceId: 'workspace-id',
|
||||
user: { id: 'impersonator-user-id' },
|
||||
workspace: { id: 'workspace-id' },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -290,6 +311,7 @@ describe('ImpersonationService', () => {
|
||||
workspaceId: 'other-workspace-id',
|
||||
user: { id: 'impersonator-user-id' },
|
||||
workspace: { id: 'other-workspace-id' },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
@@ -327,6 +349,7 @@ describe('ImpersonationService', () => {
|
||||
workspaceId: 'impersonator-workspace-id',
|
||||
user: { id: 'impersonator-user-id', canImpersonate: false },
|
||||
workspace: { id: 'impersonator-workspace-id', allowImpersonation: true },
|
||||
twoFactorAuthenticationMethods: [],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
@@ -354,4 +377,238 @@ describe('ImpersonationService', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('2FA requirements for server-level impersonation', () => {
|
||||
it('should allow server-level impersonation when 2FA is enabled and verified', async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'NODE_ENV') {
|
||||
return NodeEnvironment.PRODUCTION;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
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: true },
|
||||
workspace: {
|
||||
id: 'impersonator-workspace-id',
|
||||
allowImpersonation: true,
|
||||
},
|
||||
twoFactorAuthenticationMethods: [
|
||||
{
|
||||
id: '2fa-method-id',
|
||||
status: OTPStatus.VERIFIED,
|
||||
strategy: 'TOTP',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
LoginTokenServiceGenerateLoginTokenMock.mockResolvedValueOnce({
|
||||
token: 'mock-login-token',
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.impersonate(
|
||||
'target-user-id',
|
||||
'target-workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
workspace: {
|
||||
id: 'target-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 2FA is not enabled for server-level impersonation in production', async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'NODE_ENV') {
|
||||
return NodeEnvironment.PRODUCTION;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
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: true },
|
||||
workspace: {
|
||||
id: 'impersonator-workspace-id',
|
||||
allowImpersonation: true,
|
||||
},
|
||||
twoFactorAuthenticationMethods: [], // No 2FA methods
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.impersonate(
|
||||
'target-user-id',
|
||||
'target-workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when 2FA is not verified for server-level impersonation in production', async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'NODE_ENV') {
|
||||
return NodeEnvironment.PRODUCTION;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
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: true },
|
||||
workspace: {
|
||||
id: 'impersonator-workspace-id',
|
||||
allowImpersonation: true,
|
||||
},
|
||||
twoFactorAuthenticationMethods: [
|
||||
{
|
||||
id: '2fa-method-id',
|
||||
status: OTPStatus.PENDING, // Not verified
|
||||
strategy: 'TOTP',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.impersonate(
|
||||
'target-user-id',
|
||||
'target-workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow server-level impersonation without 2FA in development environment', async () => {
|
||||
TwentyConfigServiceGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'NODE_ENV') {
|
||||
return NodeEnvironment.DEVELOPMENT;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
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: true },
|
||||
workspace: {
|
||||
id: 'impersonator-workspace-id',
|
||||
allowImpersonation: true,
|
||||
},
|
||||
twoFactorAuthenticationMethods: [], // No 2FA methods, but should be allowed in dev
|
||||
};
|
||||
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockToImpersonateUserWorkspace,
|
||||
);
|
||||
UserWorkspaceFindOneMock.mockResolvedValueOnce(
|
||||
mockImpersonatorUserWorkspace,
|
||||
);
|
||||
|
||||
LoginTokenServiceGenerateLoginTokenMock.mockResolvedValueOnce({
|
||||
token: 'mock-login-token',
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.impersonate(
|
||||
'target-user-id',
|
||||
'target-workspace-id',
|
||||
'impersonator-user-workspace-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
workspace: {
|
||||
id: 'target-workspace-id',
|
||||
workspaceUrls: {
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'https://twenty.twenty.com',
|
||||
},
|
||||
},
|
||||
loginToken: {
|
||||
token: 'mock-login-token',
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
-2
@@ -4,6 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
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 {
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
} 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';
|
||||
import { twoFactorAuthenticationMethodsValidator } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation';
|
||||
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';
|
||||
@@ -23,6 +27,7 @@ export class ImpersonationService {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(UserWorkspace)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
@@ -45,7 +50,7 @@ export class ImpersonationService {
|
||||
const impersonatorUserWorkspace =
|
||||
await this.userWorkspaceRepository.findOne({
|
||||
where: { id: impersonatorUserWorkspaceId },
|
||||
relations: ['user', 'workspace'],
|
||||
relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'],
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -67,11 +72,39 @@ export class ImpersonationService {
|
||||
impersonatorUserWorkspace.workspace.allowImpersonation === true;
|
||||
|
||||
if (isServerLevelImpersonation) {
|
||||
if (!hasServerLevelImpersonatePermission)
|
||||
if (!hasServerLevelImpersonatePermission) {
|
||||
throw new AuthException(
|
||||
'Impersonation not enabled for the impersonator user or the target workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const isDevelopment =
|
||||
this.twentyConfigService.get('NODE_ENV') ===
|
||||
NodeEnvironment.DEVELOPMENT;
|
||||
|
||||
if (isDevelopment) {
|
||||
return this.generateImpersonationLoginToken(
|
||||
impersonatorUserWorkspace,
|
||||
toImpersonateUserWorkspace,
|
||||
'server',
|
||||
);
|
||||
}
|
||||
|
||||
const has2FAEnabled =
|
||||
twoFactorAuthenticationMethodsValidator.areDefined(
|
||||
impersonatorUserWorkspace.twoFactorAuthenticationMethods,
|
||||
) &&
|
||||
twoFactorAuthenticationMethodsValidator.areVerified(
|
||||
impersonatorUserWorkspace.twoFactorAuthenticationMethods,
|
||||
);
|
||||
|
||||
if (!has2FAEnabled) {
|
||||
throw new AuthException(
|
||||
'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return this.generateImpersonationLoginToken(
|
||||
impersonatorUserWorkspace,
|
||||
|
||||
Reference in New Issue
Block a user