fix: Server-level impersonation doesn't bypass 2FA when enabled (#14340)

## Description

- This PR solves the a sub-issue from
https://github.com/twentyhq/core-team-issues/issues/1421
- impersonation tokens bypasses 2FA as intended 
- Added Audit trails to cover all impersonation events
- Added Proper testing coverage

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Harshit Singh
2025-09-07 19:23:23 +05:30
committed by GitHub
parent 214375556a
commit b78d139db5
22 changed files with 365 additions and 61 deletions
@@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
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,
@@ -77,6 +78,14 @@ describe('AdminPanelService', () => {
TwentyConfigServiceGetVariableWithMetadataMock,
},
},
{
provide: AuditService,
useValue: {
createContext: jest.fn().mockReturnValue({
insertWorkspaceEvent: jest.fn(),
}),
},
},
],
}).compile();
@@ -108,7 +117,11 @@ describe('AdminPanelService', () => {
expiresAt: new Date(),
});
const result = await service.impersonate('user-id', 'workspace-id');
const result = await service.impersonate(
'user-id',
'workspace-id',
'user-id',
);
expect(UserFindOneMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -126,6 +139,8 @@ describe('AdminPanelService', () => {
expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
'user@example.com',
'workspace-id',
'impersonation',
{ impersonatorUserId: 'user-id' },
);
expect(result).toEqual(
@@ -149,11 +164,11 @@ describe('AdminPanelService', () => {
UserFindOneMock.mockReturnValueOnce(null);
await expect(
service.impersonate('invalid-user-id', 'workspace-id'),
service.impersonate('invalid-user-id', 'workspace-id', 'user-id'),
).rejects.toThrow(
new AuthException(
'User not found or impersonation not enable on workspace',
AuthExceptionCode.INVALID_INPUT,
'User not found in workspace or impersonation not enabled',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
),
);
@@ -5,11 +5,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
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 { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { HealthModule } from 'src/engine/core-modules/health/health.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';
@Module({
@@ -21,6 +23,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
RedisClientModule,
TerminusModule,
FeatureFlagModule,
AuditModule,
TelemetryModule,
],
providers: [AdminPanelResolver, AdminPanelService, AdminPanelHealthService],
exports: [AdminPanelService],
@@ -15,6 +15,10 @@ import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup
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';
@@ -26,6 +30,8 @@ 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';
@@ -53,8 +59,20 @@ export class AdminPanelResolver {
@Mutation(() => ImpersonateOutput)
async impersonate(
@Args() { workspaceId, userId }: ImpersonateInput,
@AuthUser() adminUser: User,
): Promise<ImpersonateOutput> {
return await this.adminService.impersonate(userId, workspaceId);
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)
@@ -11,6 +11,8 @@ import { type ConfigVariablesGroupData } from 'src/engine/core-modules/admin-pan
import { type ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
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,
@@ -25,6 +27,7 @@ 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 {
@@ -32,11 +35,16 @@ export class AdminPanelService {
private readonly loginTokenService: LoginTokenService,
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
private readonly auditService: AuditService,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async impersonate(userId: string, workspaceId: string) {
async impersonate(
userId: string,
workspaceId: string,
impersonatorUserId: string,
) {
const user = await this.userRepository.findOne({
where: {
id: userId,
@@ -53,25 +61,60 @@ export class AdminPanelService {
userValidator.assertIsDefinedOrThrow(
user,
new AuthException(
'User not found or impersonation not enable on workspace',
AuthExceptionCode.INVALID_INPUT,
'User not found in workspace or impersonation not enabled',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
),
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
user.userWorkspaces[0].workspace.id,
);
const auditService = this.auditService.createContext({
workspaceId: user.userWorkspaces[0].workspace.id,
userId: impersonatorUserId,
});
return {
workspace: {
id: user.userWorkspaces[0].workspace.id,
workspaceUrls: this.domainManagerService.getWorkspaceUrls(
user.userWorkspaces[0].workspace,
),
},
loginToken,
};
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> {