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:
-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('@');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user