diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminWorkspaceContent.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminWorkspaceContent.tsx index 69be9adec0..7f90cfe3a8 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminWorkspaceContent.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminWorkspaceContent.tsx @@ -20,7 +20,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils'; -import { Chip, AvatarChip } from 'twenty-ui/components'; +import { AvatarChip, Chip } from 'twenty-ui/components'; import { H2Title, IconEyeShare, @@ -92,6 +92,7 @@ export const SettingsAdminWorkspaceContent = ({ return executeImpersonationRedirect( workspace.workspaceUrls, loginToken.token, + '_blank', ); }, onError: (error) => { diff --git a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationRedirect.ts b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationRedirect.ts index 70bb4e36c5..5ecbdfcc21 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationRedirect.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationRedirect.ts @@ -9,11 +9,13 @@ export const useImpersonationRedirect = () => { const executeImpersonationRedirect = async ( workspaceUrls: WorkspaceUrls, loginToken: string, + target: string = '_self', ) => { return await redirectToWorkspaceDomain( getWorkspaceUrl(workspaceUrls), AppPath.Verify, { loginToken }, + target, ); }; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts index 7e81bc39fb..1d6c407a80 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts @@ -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, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index 4359d6e711..910b577b9c 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -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], diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 20d1a3c485..b75c425ac4 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -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 { - 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) diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts index 8bd6a234e5..3633bc39d4 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -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, ) {} - 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 { diff --git a/packages/twenty-server/src/engine/core-modules/audit/README.md b/packages/twenty-server/src/engine/core-modules/audit/README.md index 17dd3f7bfb..64f22973ec 100644 --- a/packages/twenty-server/src/engine/core-modules/audit/README.md +++ b/packages/twenty-server/src/engine/core-modules/audit/README.md @@ -23,23 +23,23 @@ export class MyService { async doSomething() { // Create an analytics context - const analytics = this.auditService.createContext({ + const auditService = this.auditService.createContext({ workspaceId: 'workspace-id', userId: 'user-id', }); // Track a workspace event - analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {}); + auditService.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {}); // Track an object event - analytics.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, { + auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, { recordId: 'record-id', objectMetadataId: 'object-metadata-id', // other properties }); // Track a pageview - analytics.createPageviewEvent('page-name', { + auditService.createPageviewEvent('page-name', { href: '/path', locale: 'en-US', // other properties diff --git a/packages/twenty-server/src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event.ts b/packages/twenty-server/src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event.ts index 3d9c192faa..dac4826217 100644 --- a/packages/twenty-server/src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event.ts +++ b/packages/twenty-server/src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event.ts @@ -26,26 +26,26 @@ export class CreateAuditLogFromInternalEvent { } : eventData.properties; - const analytics = this.auditService.createContext({ + const auditService = this.auditService.createContext({ workspaceId: workspaceEventBatch.workspaceId, userId: eventData.userId, }); // Since these are object record events, we use createObjectEvent if (workspaceEventBatch.name.endsWith('.updated')) { - analytics.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, { + auditService.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, { ...eventProperties, recordId: eventData.recordId, objectMetadataId: eventData.objectMetadata.id, }); } else if (workspaceEventBatch.name.endsWith('.created')) { - analytics.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, { + auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, { ...eventProperties, recordId: eventData.recordId, objectMetadataId: eventData.objectMetadata.id, }); } else if (workspaceEventBatch.name.endsWith('.deleted')) { - analytics.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, { + auditService.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, { ...eventProperties, recordId: eventData.recordId, objectMetadataId: eventData.objectMetadata.id, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index e30905af97..e08ebe8c9a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -8,6 +8,7 @@ import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; +import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller'; import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller'; @@ -107,6 +108,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; UserRoleModule, TwoFactorAuthenticationModule, ApiKeyModule, + AuditModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 90ebee9d5a..13cdf8612d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.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'; @@ -125,6 +126,14 @@ describe('AuthResolver', () => { provide: TwentyConfigService, useValue: {}, }, + { + provide: AuditService, + useValue: { + createContext: jest.fn().mockReturnValue({ + insertWorkspaceEvent: jest.fn(), + }), + }, + }, // { // provide: OAuthService, // useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 711a739fc2..f8e593eca4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -22,6 +22,8 @@ import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filt // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +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, @@ -40,7 +42,10 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + JwtTokenTypeEnum, + LoginTokenJwtPayload, +} from 'src/engine/core-modules/auth/types/auth-context.type'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @@ -53,6 +58,7 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input'; import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter'; 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'; @@ -113,6 +119,7 @@ export class AuthResolver { private userWorkspaceService: UserWorkspaceService, private emailVerificationTokenService: EmailVerificationTokenService, private sSOService: SSOService, + private readonly auditService: AuditService, ) {} @UseGuards(CaptchaGuard, PublicEndpointGuard) @@ -536,44 +543,174 @@ export class AuthResolver { @Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput, @Args('origin') origin: string, ): Promise { - const { - sub: email, - workspaceId, - authProvider, - } = await this.loginTokenService.verifyLoginToken( + const tokenPayload = await this.validateAndDecodeLoginToken( getAuthTokensFromLoginTokenInput.loginToken, ); + const workspace = await this.validateWorkspaceAccess( + origin, + tokenPayload.workspaceId, + ); + + const { user, userWorkspace } = await this.validateUserAccess( + tokenPayload.sub, + tokenPayload.workspaceId, + ); + + if (tokenPayload.authProvider === AuthProviderEnum.Impersonation) { + await this.validateAndLogImpersonation( + tokenPayload, + workspace, + user.email, + ); + } else { + await this.validateRegularAuthentication(workspace, userWorkspace); + } + + return await this.authService.verify( + user.email, + workspace.id, + tokenPayload.authProvider, + ); + } + + private async validateAndDecodeLoginToken( + loginToken: string, + ): Promise { + return await this.loginTokenService.verifyLoginToken(loginToken); + } + + private async validateWorkspaceAccess( + origin: string, + tokenWorkspaceId: string, + ): Promise { const workspace = await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( origin, ); - workspaceValidator.assertIsDefinedOrThrow(workspace); + workspaceValidator.assertIsDefinedOrThrow( + workspace, + new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ), + ); - if (workspaceId !== workspace.id) { + if (tokenWorkspaceId !== workspace.id) { throw new AuthException( 'Token is not valid for this workspace', AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } + return workspace; + } + + private async validateUserAccess( + email: string, + workspaceId: string, + ): Promise<{ user: User; userWorkspace: UserWorkspace }> { const user = await this.userService.getUserByEmail(email); await this.authService.checkIsEmailVerified(user.isEmailVerified); - const currentUserWorkspace = + const userWorkspace = await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({ userId: user.id, workspaceId, }); + return { user, userWorkspace }; + } + + private async validateRegularAuthentication( + workspace: Workspace, + userWorkspace: UserWorkspace, + ): Promise { await this.twoFactorAuthenticationService.validateTwoFactorAuthenticationRequirement( workspace, - currentUserWorkspace.twoFactorAuthenticationMethods, + userWorkspace.twoFactorAuthenticationMethods, ); + } - return await this.authService.verify(email, workspace.id, authProvider); + private async validateAndLogImpersonation( + tokenPayload: LoginTokenJwtPayload, + workspace: Workspace, + targetUserEmail: string, + ): Promise { + const { impersonatorUserId } = tokenPayload; + + const auditService = this.auditService.createContext({ + workspaceId: workspace.id, + userId: impersonatorUserId, + }); + + await auditService.insertWorkspaceEvent(MONITORING_EVENT, { + eventName: 'server.impersonation.token_exchange_attempt', + message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserId}`, + }); + + if (workspace.allowImpersonation !== true) { + throw new AuthException( + 'Impersonation not allowed on this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (!impersonatorUserId) { + await auditService.insertWorkspaceEvent(MONITORING_EVENT, { + eventName: 'server.impersonation.token_exchange_failed', + message: `Invalid impersonation token (missing impersonator user ID) for ${targetUserEmail}`, + }); + throw new AuthException( + 'Invalid impersonation token (missing impersonator user ID)', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const impersonatorUser = await this.userRepository.findOne({ + where: { id: impersonatorUserId }, + }); + + if (!impersonatorUser) { + await auditService.insertWorkspaceEvent(MONITORING_EVENT, { + eventName: 'server.impersonation.token_exchange_failed', + message: `Impersonator user not found: ${impersonatorUserId} for ${targetUserEmail}`, + }); + throw new AuthException( + 'Impersonator user not found', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (impersonatorUser.canImpersonate !== true) { + await auditService.insertWorkspaceEvent(MONITORING_EVENT, { + eventName: 'server.impersonation.token_exchange_failed', + message: `User not authorized to impersonate: ${impersonatorUserId} for ${targetUserEmail}`, + }); + throw new AuthException( + 'User not authorized to impersonate', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + await this.logImpersonationEvent(workspace.id, impersonatorUserId); + } + + private async logImpersonationEvent( + workspaceId: string, + impersonatorUserId: string, + ): Promise { + const auditService = this.auditService.createContext({ + workspaceId, + userId: impersonatorUserId, + }); + + await auditService.insertWorkspaceEvent(MONITORING_EVENT, { + eventName: 'server.impersonation.login_token_exchanged', + message: 'Impersonation token exchanged', + }); } @Mutation(() => AuthorizeApp) diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 3a5f4b902e..4630a463f0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -268,7 +268,7 @@ export class AuthService { async verify( email: string, workspaceId: string, - authProvider?: AuthProviderEnum, + authProvider: AuthProviderEnum, ): Promise { if (!email) { throw new AuthException( diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 421537268b..314ff0d77f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -13,6 +13,7 @@ import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrap 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 { User } from 'src/engine/core-modules/user/user.entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -131,7 +132,11 @@ describe('AccessTokenService', () => { } as any); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateAccessToken({ userId, workspaceId }); + const result = await service.generateAccessToken({ + userId, + workspaceId, + authProvider: AuthProviderEnum.Password, + }); expect(result).toEqual({ token: mockToken, @@ -155,6 +160,7 @@ describe('AccessTokenService', () => { service.generateAccessToken({ userId: 'non-existent-user', workspaceId: 'workspace-id', + authProvider: AuthProviderEnum.Password, }), ).rejects.toThrow(AuthException); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index eed81da2ba..ae2ce35bb2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; 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 { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { LoginTokenService } from './login-token.service'; @@ -55,7 +56,11 @@ describe('LoginTokenService', () => { jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateLoginToken(email, workspaceId); + const result = await service.generateLoginToken( + email, + workspaceId, + AuthProviderEnum.Password, + ); expect(result).toEqual({ token: mockToken, @@ -69,12 +74,60 @@ describe('LoginTokenService', () => { 'LOGIN_TOKEN_EXPIRES_IN', ); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: email, workspaceId, type: 'LOGIN' }, + { + sub: email, + workspaceId, + type: 'LOGIN', + authProvider: AuthProviderEnum.Password, + impersonatorUserId: undefined, + }, { secret: mockSecret, expiresIn: mockExpiresIn }, ); }); }); + describe('generateLoginToken with impersonation', () => { + it('should include impersonatorUserId in JWT payload when using Impersonation auth provider', async () => { + const email = 'test@example.com'; + const mockSecret = 'mock-secret'; + const mockToken = 'mock-token'; + const workspaceId = 'workspace-id'; + const impersonatorUserId = 'impersonator-id'; + + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue(mockSecret); + jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateLoginToken( + email, + workspaceId, + AuthProviderEnum.Impersonation, + { impersonatorUserId }, + ); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith( + 'LOGIN', + workspaceId, + ); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { + sub: email, + workspaceId, + type: 'LOGIN', + authProvider: AuthProviderEnum.Impersonation, + impersonatorUserId, + }, + { secret: mockSecret, expiresIn: expect.any(String) }, + ); + }); + }); + describe('verifyLoginToken', () => { it('should verify a login token successfully', async () => { const mockToken = 'valid-token'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index 806c903770..06dc8d9779 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -4,12 +4,12 @@ import { addMilliseconds } from 'date-fns'; import ms from 'ms'; 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 { type LoginTokenJwtPayload, 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 { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() @@ -22,13 +22,15 @@ export class LoginTokenService { async generateLoginToken( email: string, workspaceId: string, - authProvider?: AuthProviderEnum, + authProvider: AuthProviderEnum, + options?: { impersonatorUserId?: string }, ): Promise { const jwtPayload: LoginTokenJwtPayload = { type: JwtTokenTypeEnum.LOGIN, sub: email, workspaceId, authProvider, + impersonatorUserId: options?.impersonatorUserId, }; const secret = this.jwtWrapperService.generateAppSecret( diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts index 42a383f5f2..16c03048ec 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -10,6 +10,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type User } from 'src/engine/core-modules/user/user.entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { RenewTokenService } from './renew-token.service'; @@ -86,7 +87,7 @@ describe('RenewTokenService', () => { jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({ user: mockUser, token: mockAppToken as AppToken, - authProvider: undefined, + authProvider: AuthProviderEnum.Password, targetedTokenType: JwtTokenTypeEnum.ACCESS, }); jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); @@ -114,9 +115,10 @@ describe('RenewTokenService', () => { expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({ userId: mockUser.id, workspaceId: mockWorkspaceId, + authProvider: AuthProviderEnum.Password, }); expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({ - authProvider: undefined, + authProvider: AuthProviderEnum.Password, targetedTokenType: JwtTokenTypeEnum.ACCESS, userId: mockUser.id, workspaceId: mockWorkspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts index b22a6ac34e..4e7fce9b71 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -14,6 +14,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ 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 { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() export class RenewTokenService { @@ -57,6 +58,10 @@ 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 = isDefined(authProvider) && targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC @@ -69,13 +74,13 @@ export class RenewTokenService { : await this.accessTokenService.generateAccessToken({ userId: user.id, workspaceId, - authProvider, + authProvider: resolvedAuthProvider, }); const refreshToken = await this.refreshTokenService.generateRefreshToken({ userId: user.id, workspaceId, - authProvider, + authProvider: resolvedAuthProvider, targetedTokenType, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 882461261e..90c439a555 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -43,7 +43,8 @@ export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.LOGIN; workspaceId: string; - authProvider?: AuthProviderEnum; + authProvider: AuthProviderEnum; + impersonatorUserId?: string; }; export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & { @@ -81,7 +82,7 @@ export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { userId: string; workspaceMemberId?: string; userWorkspaceId: string; - authProvider?: AuthProviderEnum; + authProvider: AuthProviderEnum; }; export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & { diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts index 2fcc80a6f2..c43fd94ea0 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts @@ -71,7 +71,7 @@ export class CloudflareController { if (!workspace) return; - const analytics = this.auditService.createContext({ + const auditService = this.auditService.createContext({ workspaceId: workspace.id, }); @@ -100,7 +100,10 @@ export class CloudflareController { ...workspaceUpdated, }); - await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {}); + await auditService.insertWorkspaceEvent( + CUSTOM_DOMAIN_ACTIVATED_EVENT, + {}, + ); } return res.status(200).send(); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts index 302f19303a..4cc1e35fdf 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts @@ -199,11 +199,11 @@ export class CustomDomainService { await this.workspaceRepository.save(workspace); - const analytics = this.auditService.createContext({ + const auditService = this.auditService.createContext({ workspaceId: workspace.id, }); - analytics.insertWorkspaceEvent( + auditService.insertWorkspaceEvent( workspace.isCustomDomainEnabled ? CUSTOM_DOMAIN_ACTIVATED_EVENT : CUSTOM_DOMAIN_DEACTIVATED_EVENT, diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts b/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts index bbb9649684..e3bb5dc169 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts +++ b/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts @@ -48,7 +48,7 @@ export class CallWebhookJob { webhookId: data.webhookId, eventName: data.eventName, }; - const analytics = this.auditService.createContext({ + const auditService = this.auditService.createContext({ workspaceId: data.workspaceId, }); @@ -79,13 +79,13 @@ export class CallWebhookJob { const success = response.status >= 200 && response.status < 300; - analytics.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, { + auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, { status: response.status, success, ...commonPayload, }); } catch (err) { - analytics.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, { + auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, { success: false, ...commonPayload, ...(err.response && { status: err.response.status }), diff --git a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts index 6f8da8dde3..aba488fe52 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts @@ -3,4 +3,5 @@ export enum AuthProviderEnum { Microsoft = 'microsoft', Password = 'password', SSO = 'sso', + Impersonation = 'impersonation', }