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 437cbc56a1..0b9b7f3744 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 @@ -1,16 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; -import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; -import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module'; import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller'; +import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module'; import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module'; -import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.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'; @@ -40,10 +40,12 @@ import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module'; import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module'; +import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; +import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; @@ -59,7 +61,6 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; @@ -68,8 +69,8 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; -import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module'; import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module'; import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module'; @@ -109,6 +110,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; WorkspaceInvitationModule, EmailVerificationModule, GuardRedirectModule, + ImpersonationAuthorizationModule, MetricsModule, PermissionsModule, TwoFactorAuthenticationModule, 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 e8fc92398e..154b7c2873 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 @@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.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 { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; @@ -120,6 +121,10 @@ describe('AuthResolver', () => { provide: EmailVerificationTokenService, useValue: {}, }, + { + provide: ImpersonationAuthorizationService, + useValue: {}, + }, { provide: PermissionsService, 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 91a043c228..665b48e9a9 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 @@ -60,6 +60,9 @@ import { EmailVerificationService } from 'src/engine/core-modules/email-verifica import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type'; +import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant'; +import { IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; 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'; @@ -80,7 +83,6 @@ import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token. import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -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 { ApiKeyToken } from './dto/api-key-token.dto'; @@ -132,7 +134,7 @@ export class AuthResolver { private emailVerificationTokenService: EmailVerificationTokenService, private ssoService: SSOService, private readonly eventLogEmitterService: EventLogEmitterService, - private readonly permissionsService: PermissionsService, + private readonly impersonationAuthorizationService: ImpersonationAuthorizationService, private readonly subdomainManagerService: SubdomainManagerService, ) {} @@ -702,7 +704,7 @@ export class AuthResolver { const impersonatorUserWorkspace = await this.userWorkspaceRepository.findOne({ where: { id: impersonatorUserWorkspaceId }, - relations: ['user', 'workspace'], + relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'], }); const toImpersonateUserWorkspace = @@ -733,75 +735,49 @@ export class AuthResolver { ); } - const isServerLevelImpersonation = - toImpersonateUserWorkspace.workspace.id !== - impersonatorUserWorkspace.workspace.id; - const eventLogContext = this.eventLogEmitterService.createContext({ workspaceId: workspace.id, userId: impersonatorUserWorkspace.user.id, }); + const impersonationLevel = + this.impersonationAuthorizationService.getImpersonationLevel( + impersonatorUserWorkspace, + toImpersonateUserWorkspace, + ); + void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { - level: isServerLevelImpersonation ? 'server' : 'workspace', + level: impersonationLevel, action: 'token_exchange_attempt', message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`, }); - const hasServerLevelImpersonatePermission = - impersonatorUserWorkspace.user.canImpersonate === true && - toImpersonateUserWorkspace.workspace.allowImpersonation === true; - - if (isServerLevelImpersonation) { - if (!hasServerLevelImpersonatePermission) { - void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { - level: 'server', - action: 'token_exchange_failed', - message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`, - }); - - throw new AuthException( - 'Server level impersonation not allowed on this workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { - level: 'server', - action: '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, - }; - } - - const hasWorkspaceLevelImpersonatePermission = - await this.permissionsService.userHasWorkspaceSettingPermission({ - userWorkspaceId: impersonatorUserWorkspace.id, - setting: PermissionFlagType.IMPERSONATE, - workspaceId: workspace.id, - }); - - if (!hasWorkspaceLevelImpersonatePermission) { - void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { - level: 'workspace', - action: 'token_exchange_failed', - message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`, - }); - throw new AuthException( - 'Impersonation not allowed', - AuthExceptionCode.FORBIDDEN_EXCEPTION, + const authorizationResult = + await this.impersonationAuthorizationService.checkImpersonationAuthorization( + impersonatorUserWorkspace, + toImpersonateUserWorkspace, ); + + if (!authorizationResult.allowed) { + void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { + level: authorizationResult.level, + action: 'token_exchange_failed', + message: IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON[ + authorizationResult.reason + ]({ + targetUserEmail, + impersonatorUserId: impersonatorUserWorkspace.user.id, + }), + }); + + const { message, exceptionCode, userFriendlyMessage } = + IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason]; + + throw new AuthException(message, exceptionCode, { userFriendlyMessage }); } void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { - level: 'workspace', + level: authorizationResult.level, action: 'token_exchange_success', message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index b9cd385869..e4217f52e1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -8,6 +8,8 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { JwtAuthStrategy } from './jwt.auth.strategy'; @@ -17,6 +19,7 @@ describe('JwtAuthStrategy', () => { let userWorkspaceRepository: any; let jwtWrapperService: any; let permissionsService: any; + let twentyConfigService: any; let workspaceCacheService: any; let coreEntityCacheService: any; @@ -52,6 +55,12 @@ describe('JwtAuthStrategy', () => { userHasWorkspaceSettingPermission: jest.fn(), }; + twentyConfigService = { + get: jest.fn((key: string) => + key === 'NODE_ENV' ? NodeEnvironment.DEVELOPMENT : undefined, + ), + }; + workspaceCacheService = { getOrRecompute: jest.fn( async (workspaceId: string, cacheKeys: string[]) => { @@ -118,9 +127,12 @@ describe('JwtAuthStrategy', () => { new JwtAuthStrategy( jwtWrapperService, userWorkspaceRepository, - permissionsService, workspaceCacheService, coreEntityCacheService, + new ImpersonationAuthorizationService( + permissionsService, + twentyConfigService, + ), ); describe('API_KEY validation', () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index ca2fd73e57..20cf479486 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -4,32 +4,32 @@ import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { type SecretOrKeyProvider, Strategy } from 'passport-jwt'; -import { PermissionFlagType } from 'twenty-shared/constants'; -import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; -import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { Repository } from 'typeorm'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type'; +import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; import { type AuthContext, type AuthContextUser, } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; -import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type'; -import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; -import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; -import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { Repository } from 'typeorm'; @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -37,9 +37,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { private readonly jwtWrapperService: JwtWrapperService, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, - private readonly permissionsService: PermissionsService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly coreEntityCacheService: CoreEntityCacheService, + private readonly impersonationAuthorizationService: ImpersonationAuthorizationService, ) { const secretOrKeyProvider: SecretOrKeyProvider = ( _request, @@ -272,7 +272,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { const impersonatorUserWorkspace = await this.userWorkspaceRepository.findOne({ where: { id: payload.impersonatorUserWorkspaceId }, - relations: ['user', 'workspace'], + relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'], }); const impersonatedUserWorkspace = @@ -291,54 +291,17 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - const isServerLevelImpersonation = - impersonatorUserWorkspace.workspace.id !== - impersonatedUserWorkspace.workspace.id; - - const hasServerLevelImpersonatePermission = - impersonatorUserWorkspace.user.canImpersonate === true && - impersonatedUserWorkspace.workspace.allowImpersonation === true; - - if (isServerLevelImpersonation) { - if (!hasServerLevelImpersonatePermission) - throw new AuthException( - 'Server level impersonation not allowed', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - - return { - impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId, - impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId, - }; - } - - const hasWorkspaceLevelImpersonatePermission = - await this.permissionsService.userHasWorkspaceSettingPermission({ - userWorkspaceId: impersonatorUserWorkspace.id, - setting: PermissionFlagType.IMPERSONATE, - workspaceId: impersonatedUserWorkspace.workspace.id, - }); - - if (!hasWorkspaceLevelImpersonatePermission) { - throw new AuthException( - 'Impersonation not allowed', - AuthExceptionCode.FORBIDDEN_EXCEPTION, + const authorizationResult = + await this.impersonationAuthorizationService.checkImpersonationAuthorization( + impersonatorUserWorkspace, + impersonatedUserWorkspace, ); - } - const targetHasAdminPrivileges = - impersonatedUserWorkspace.user.canImpersonate === true || - impersonatedUserWorkspace.user.canAccessFullAdminPanel === true; + if (!authorizationResult.allowed) { + const { message, exceptionCode, userFriendlyMessage } = + IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason]; - const impersonatorHasAdminPrivileges = - impersonatorUserWorkspace.user.canImpersonate === true || - impersonatorUserWorkspace.user.canAccessFullAdminPanel === true; - - if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) { - throw new AuthException( - 'Cannot impersonate a user with admin privileges', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + throw new AuthException(message, exceptionCode, { userFriendlyMessage }); } return { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 5221174eb6..8fd700d027 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -11,6 +11,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -33,6 +34,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache PermissionsModule, WorkspaceCacheModule, CoreEntityCacheModule, + ImpersonationAuthorizationModule, ], providers: [ RenewTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts index 8a0b0cdf53..51438be101 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts @@ -45,6 +45,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => { case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED: throw new ForbiddenError(exception.message, { subCode: exception.code, + userFriendlyMessage: exception.userFriendlyMessage, }); case AuthExceptionCode.UNAUTHENTICATED: case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED: diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts new file mode 100644 index 0000000000..58c6159997 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts @@ -0,0 +1,411 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; + +const userHasWorkspaceSettingPermissionMock = jest.fn(); +const twentyConfigServiceGetMock = jest.fn(); + +const VERIFIED_TWO_FACTOR_METHODS = [ + { status: OTPStatus.VERIFIED }, +] as unknown as UserWorkspaceEntity['twoFactorAuthenticationMethods']; + +type BuildUserWorkspaceParams = { + userWorkspaceId?: string; + userId?: string; + workspaceId?: string; + canImpersonate?: boolean; + canAccessFullAdminPanel?: boolean; + allowImpersonation?: boolean; + twoFactorAuthenticationMethods?: UserWorkspaceEntity['twoFactorAuthenticationMethods']; +}; + +const buildUserWorkspace = ({ + userWorkspaceId = 'user-workspace-id', + userId = 'user-id', + workspaceId = 'workspace-id', + canImpersonate = false, + canAccessFullAdminPanel = false, + allowImpersonation = false, + twoFactorAuthenticationMethods = [], +}: BuildUserWorkspaceParams): UserWorkspaceEntity => + ({ + id: userWorkspaceId, + userId, + user: { + id: userId, + canImpersonate, + canAccessFullAdminPanel, + }, + workspace: { + id: workspaceId, + allowImpersonation, + }, + twoFactorAuthenticationMethods, + }) as unknown as UserWorkspaceEntity; + +describe('ImpersonationAuthorizationService', () => { + let service: ImpersonationAuthorizationService; + + beforeEach(async () => { + jest.clearAllMocks(); + + twentyConfigServiceGetMock.mockImplementation((key: string) => + key === 'NODE_ENV' ? NodeEnvironment.PRODUCTION : undefined, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImpersonationAuthorizationService, + { + provide: PermissionsService, + useValue: { + userHasWorkspaceSettingPermission: + userHasWorkspaceSettingPermissionMock, + }, + }, + { + provide: TwentyConfigService, + useValue: { + get: twentyConfigServiceGetMock, + }, + }, + ], + }).compile(); + + service = module.get( + ImpersonationAuthorizationService, + ); + }); + + describe('getImpersonationLevel', () => { + it('should return workspace when impersonator and target share a workspace', () => { + const impersonator = buildUserWorkspace({ workspaceId: 'workspace-1' }); + const target = buildUserWorkspace({ + userId: 'other', + workspaceId: 'workspace-1', + }); + + expect(service.getImpersonationLevel(impersonator, target)).toBe( + 'workspace', + ); + }); + + it('should return server when impersonator and target are in different workspaces', () => { + const impersonator = buildUserWorkspace({ workspaceId: 'workspace-1' }); + const target = buildUserWorkspace({ + userId: 'other', + workspaceId: 'workspace-2', + }); + + expect(service.getImpersonationLevel(impersonator, target)).toBe( + 'server', + ); + }); + }); + + describe('server-level impersonation', () => { + it('should allow when impersonator can impersonate, has verified 2FA and target workspace allows it', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + twoFactorAuthenticationMethods: VERIFIED_TWO_FACTOR_METHODS, + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ allowed: true, level: 'server' }); + expect(userHasWorkspaceSettingPermissionMock).not.toHaveBeenCalled(); + }); + + it('should deny with provisioning reason when impersonator has no 2FA method (production)', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + twoFactorAuthenticationMethods: [], + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'server', + reason: 'SERVER_LEVEL_2FA_PROVISION_REQUIRED', + }); + }); + + it('should deny with verification reason when impersonator has an unverified 2FA method (production)', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + twoFactorAuthenticationMethods: [ + { status: OTPStatus.PENDING }, + ] as unknown as UserWorkspaceEntity['twoFactorAuthenticationMethods'], + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'server', + reason: 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED', + }); + }); + + it('should allow server-level impersonation without 2FA in development', async () => { + twentyConfigServiceGetMock.mockImplementation((key: string) => + key === 'NODE_ENV' ? NodeEnvironment.DEVELOPMENT : undefined, + ); + + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + twoFactorAuthenticationMethods: [], + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ allowed: true, level: 'server' }); + }); + + it('should deny when impersonator cannot impersonate', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: false, + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'server', + reason: 'SERVER_LEVEL_NOT_ALLOWED', + }); + }); + + it('should deny when target workspace does not allow impersonation', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-2', + allowImpersonation: false, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'server', + reason: 'SERVER_LEVEL_NOT_ALLOWED', + }); + }); + + it('should NOT apply the admin-privilege check at server level (a server-level impersonator may impersonate an admin)', async () => { + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: true, + canAccessFullAdminPanel: false, + twoFactorAuthenticationMethods: VERIFIED_TWO_FACTOR_METHODS, + }); + const target = buildUserWorkspace({ + userId: 'admin-target', + workspaceId: 'workspace-2', + allowImpersonation: true, + canAccessFullAdminPanel: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ allowed: true, level: 'server' }); + }); + }); + + describe('workspace-level impersonation', () => { + it('should allow when impersonator has the IMPERSONATE permission and target is not an admin', async () => { + userHasWorkspaceSettingPermissionMock.mockResolvedValue(true); + + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-1', + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ allowed: true, level: 'workspace' }); + expect(userHasWorkspaceSettingPermissionMock).toHaveBeenCalledWith({ + userWorkspaceId: impersonator.id, + setting: PermissionFlagType.IMPERSONATE, + workspaceId: target.workspace.id, + }); + }); + + it('should deny when impersonator lacks the IMPERSONATE permission', async () => { + userHasWorkspaceSettingPermissionMock.mockResolvedValue(false); + + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + }); + const target = buildUserWorkspace({ + userId: 'target', + workspaceId: 'workspace-1', + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'workspace', + reason: 'WORKSPACE_LEVEL_NOT_ALLOWED', + }); + }); + + it('should deny when a non-admin tries to impersonate an admin (canAccessFullAdminPanel)', async () => { + userHasWorkspaceSettingPermissionMock.mockResolvedValue(true); + + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: false, + canAccessFullAdminPanel: false, + }); + const target = buildUserWorkspace({ + userId: 'admin-target', + workspaceId: 'workspace-1', + canAccessFullAdminPanel: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'workspace', + reason: 'TARGET_HAS_ADMIN_PRIVILEGES', + }); + }); + + it('should deny when a non-admin tries to impersonate an admin (canImpersonate)', async () => { + userHasWorkspaceSettingPermissionMock.mockResolvedValue(true); + + const impersonator = buildUserWorkspace({ + userId: 'impersonator', + workspaceId: 'workspace-1', + canImpersonate: false, + canAccessFullAdminPanel: false, + }); + const target = buildUserWorkspace({ + userId: 'admin-target', + workspaceId: 'workspace-1', + canImpersonate: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ + allowed: false, + level: 'workspace', + reason: 'TARGET_HAS_ADMIN_PRIVILEGES', + }); + }); + + it('should allow an admin to impersonate another admin', async () => { + userHasWorkspaceSettingPermissionMock.mockResolvedValue(true); + + const impersonator = buildUserWorkspace({ + userId: 'admin-impersonator', + workspaceId: 'workspace-1', + canAccessFullAdminPanel: true, + }); + const target = buildUserWorkspace({ + userId: 'admin-target', + workspaceId: 'workspace-1', + canAccessFullAdminPanel: true, + }); + + const result = await service.checkImpersonationAuthorization( + impersonator, + target, + ); + + expect(result).toEqual({ allowed: true, level: 'workspace' }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts index 5eecad9566..741ceff15c 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts @@ -3,13 +3,14 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; -import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.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 { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.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'; @@ -36,6 +37,7 @@ describe('ImpersonationService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ImpersonationService, + ImpersonationAuthorizationService, { provide: getRepositoryToken(UserEntity), useValue: { @@ -366,7 +368,7 @@ describe('ImpersonationService', () => { ), ).rejects.toThrow( new AuthException( - 'Impersonation not enabled for the impersonator user or the target workspace', + 'Server level impersonation not allowed', AuthExceptionCode.FORBIDDEN_EXCEPTION, ), ); @@ -409,7 +411,7 @@ describe('ImpersonationService', () => { ), ).rejects.toThrow( new AuthException( - 'Impersonation not enabled for the impersonator user or the target workspace', + 'Server level impersonation not allowed', AuthExceptionCode.FORBIDDEN_EXCEPTION, ), ); @@ -698,7 +700,7 @@ describe('ImpersonationService', () => { ).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, + AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED, ), ); }); @@ -752,8 +754,8 @@ describe('ImpersonationService', () => { ), ).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, + 'Two-factor authentication is required for server-level impersonation. Please verify your 2FA method before attempting to impersonate users.', + AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED, ), ); }); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts new file mode 100644 index 0000000000..6ecb0c7566 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts @@ -0,0 +1,47 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; + +import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception'; +import { type ImpersonationDenialReason } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; + +type ImpersonationDenial = { + message: string; + exceptionCode: (typeof AuthExceptionCode)[keyof typeof AuthExceptionCode]; + userFriendlyMessage: MessageDescriptor; +}; + +export const IMPERSONATION_DENIAL_BY_REASON: Record< + ImpersonationDenialReason, + ImpersonationDenial +> = { + SERVER_LEVEL_NOT_ALLOWED: { + message: 'Server level impersonation not allowed', + exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION, + userFriendlyMessage: msg`Server-level impersonation is not enabled for this user or workspace.`, + }, + SERVER_LEVEL_2FA_PROVISION_REQUIRED: { + message: + 'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.', + exceptionCode: + AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED, + userFriendlyMessage: msg`Set up two-factor authentication before impersonating users in another workspace.`, + }, + SERVER_LEVEL_2FA_VERIFICATION_REQUIRED: { + message: + 'Two-factor authentication is required for server-level impersonation. Please verify your 2FA method before attempting to impersonate users.', + exceptionCode: + AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED, + userFriendlyMessage: msg`Verify your two-factor authentication before impersonating users in another workspace.`, + }, + WORKSPACE_LEVEL_NOT_ALLOWED: { + message: 'Impersonation not allowed', + exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION, + userFriendlyMessage: msg`You do not have permission to impersonate users in this workspace.`, + }, + TARGET_HAS_ADMIN_PRIVILEGES: { + message: + 'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.', + exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION, + userFriendlyMessage: msg`Only administrators can impersonate users with admin privileges.`, + }, +}; diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts new file mode 100644 index 0000000000..68454dddb1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts @@ -0,0 +1,23 @@ +import { type ImpersonationDenialReason } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; + +export const IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON: Record< + ImpersonationDenialReason, + (params: { targetUserEmail: string; impersonatorUserId: string }) => string +> = { + SERVER_LEVEL_NOT_ALLOWED: ({ targetUserEmail, impersonatorUserId }) => + `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserId}`, + SERVER_LEVEL_2FA_PROVISION_REQUIRED: ({ + targetUserEmail, + impersonatorUserId, + }) => + `Server level impersonation denied (2FA provisioning required) for ${targetUserEmail} by userId ${impersonatorUserId}`, + SERVER_LEVEL_2FA_VERIFICATION_REQUIRED: ({ + targetUserEmail, + impersonatorUserId, + }) => + `Server level impersonation denied (2FA verification required) for ${targetUserEmail} by userId ${impersonatorUserId}`, + WORKSPACE_LEVEL_NOT_ALLOWED: ({ targetUserEmail, impersonatorUserId }) => + `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserId}`, + TARGET_HAS_ADMIN_PRIVILEGES: ({ targetUserEmail, impersonatorUserId }) => + `Impersonation of admin user ${targetUserEmail} denied for non-admin userId ${impersonatorUserId}`, +}; diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts new file mode 100644 index 0000000000..b3c88df595 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; + +@Module({ + imports: [PermissionsModule], + providers: [ImpersonationAuthorizationService], + exports: [ImpersonationAuthorizationService], +}) +export class ImpersonationAuthorizationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts index f093e44c69..48bb1b4ab1 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; +import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module'; import { ImpersonationResolver } from 'src/engine/core-modules/impersonation/impersonation.resolver'; import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -17,6 +18,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role. @Module({ imports: [ AuthModule, + ImpersonationAuthorizationModule, UserWorkspaceModule, PermissionsModule, RoleModule, diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts index 2e55b373b7..0613144e8f 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards, UsePipes } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation } from '@nestjs/graphql'; import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input'; @@ -7,8 +7,11 @@ 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 { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { ImpersonatePermissionGuard } from 'src/engine/guards/impersonate-permission.guard'; @@ -19,6 +22,11 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @MetadataResolver() @UsePipes(ResolverValidationPipe) +@UseFilters( + AuthGraphqlApiExceptionFilter, + PermissionsGraphqlApiExceptionFilter, + PreventNestToAutoLogGraphqlErrorsFilter, +) export class ImpersonationResolver { constructor(private readonly impersonationService: ImpersonationService) {} diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts new file mode 100644 index 0000000000..0547d6bd63 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@nestjs/common'; + +import { PermissionFlagType } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; + +import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; + +export type ImpersonationLevel = 'server' | 'workspace'; + +export type ImpersonationDenialReason = + | 'SERVER_LEVEL_NOT_ALLOWED' + | 'SERVER_LEVEL_2FA_PROVISION_REQUIRED' + | 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED' + | 'WORKSPACE_LEVEL_NOT_ALLOWED' + | 'TARGET_HAS_ADMIN_PRIVILEGES'; + +export type ImpersonationAuthorizationResult = + | { allowed: true; level: ImpersonationLevel } + | { + allowed: false; + level: ImpersonationLevel; + reason: ImpersonationDenialReason; + }; + +@Injectable() +export class ImpersonationAuthorizationService { + constructor( + private readonly permissionsService: PermissionsService, + private readonly twentyConfigService: TwentyConfigService, + ) {} + + getImpersonationLevel( + impersonatorUserWorkspace: UserWorkspaceEntity, + targetUserWorkspace: UserWorkspaceEntity, + ): ImpersonationLevel { + return targetUserWorkspace.workspace.id !== + impersonatorUserWorkspace.workspace.id + ? 'server' + : 'workspace'; + } + + async checkImpersonationAuthorization( + impersonatorUserWorkspace: UserWorkspaceEntity, + targetUserWorkspace: UserWorkspaceEntity, + ): Promise { + const level = this.getImpersonationLevel( + impersonatorUserWorkspace, + targetUserWorkspace, + ); + + if (level === 'server') { + const hasServerLevelImpersonatePermission = + impersonatorUserWorkspace.user.canImpersonate === true && + targetUserWorkspace.workspace.allowImpersonation === true; + + if (!hasServerLevelImpersonatePermission) { + return { allowed: false, level, reason: 'SERVER_LEVEL_NOT_ALLOWED' }; + } + + if (this.isTwoFactorRequiredForServerLevelImpersonation()) { + const twoFactorDenialReason = this.getServerLevelTwoFactorDenialReason( + impersonatorUserWorkspace, + ); + + if (isDefined(twoFactorDenialReason)) { + return { allowed: false, level, reason: twoFactorDenialReason }; + } + } + + return { allowed: true, level }; + } + + const hasWorkspaceLevelImpersonatePermission = + await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId: impersonatorUserWorkspace.id, + setting: PermissionFlagType.IMPERSONATE, + workspaceId: targetUserWorkspace.workspace.id, + }); + + if (!hasWorkspaceLevelImpersonatePermission) { + return { allowed: false, level, reason: 'WORKSPACE_LEVEL_NOT_ALLOWED' }; + } + + if ( + userHasAdminPrivileges(targetUserWorkspace.user) && + !userHasAdminPrivileges(impersonatorUserWorkspace.user) + ) { + return { allowed: false, level, reason: 'TARGET_HAS_ADMIN_PRIVILEGES' }; + } + + return { allowed: true, level }; + } + + private isTwoFactorRequiredForServerLevelImpersonation(): boolean { + return ( + this.twentyConfigService.get('NODE_ENV') !== NodeEnvironment.DEVELOPMENT + ); + } + + private getServerLevelTwoFactorDenialReason( + impersonatorUserWorkspace: UserWorkspaceEntity, + ): ImpersonationDenialReason | undefined { + const twoFactorAuthenticationMethods = + impersonatorUserWorkspace.twoFactorAuthenticationMethods; + + if ( + !twoFactorAuthenticationMethodsValidator.areDefined( + twoFactorAuthenticationMethods, + ) + ) { + return 'SERVER_LEVEL_2FA_PROVISION_REQUIRED'; + } + + if ( + !twoFactorAuthenticationMethodsValidator.areVerified( + twoFactorAuthenticationMethods, + ) + ) { + return 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED'; + } + + return undefined; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts index bcf3a1d492..cb593af885 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts @@ -1,25 +1,21 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; - -import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; -import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; 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 { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.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 { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; +import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant'; +import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @Injectable() export class ImpersonationService { @@ -27,10 +23,9 @@ export class ImpersonationService { private readonly eventLogEmitterService: EventLogEmitterService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly loginTokenService: LoginTokenService, - private readonly twentyConfigService: TwentyConfigService, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, - private readonly permissionsService: PermissionsService, + private readonly impersonationAuthorizationService: ImpersonationAuthorizationService, ) {} async impersonate( @@ -72,89 +67,23 @@ export class ImpersonationService { ); } - const isServerLevelImpersonation = - toImpersonateUserWorkspace.workspace.id !== - impersonatorUserWorkspace.workspace.id; - - const hasServerLevelImpersonatePermission = - impersonatorUserWorkspace.user.canImpersonate === true && - toImpersonateUserWorkspace.workspace.allowImpersonation === true; - - if (isServerLevelImpersonation) { - 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.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED, - ); - } - - return this.generateImpersonationLoginToken( + const authorizationResult = + await this.impersonationAuthorizationService.checkImpersonationAuthorization( impersonatorUserWorkspace, toImpersonateUserWorkspace, - 'server', ); - } - const hasWorkspaceLevelImpersonatePermission = - await this.permissionsService.userHasWorkspaceSettingPermission({ - userWorkspaceId: impersonatorUserWorkspace.id, - setting: PermissionFlagType.IMPERSONATE, - workspaceId: workspaceId, - }); + if (!authorizationResult.allowed) { + const { message, exceptionCode, userFriendlyMessage } = + IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason]; - if (!hasWorkspaceLevelImpersonatePermission) { - throw new AuthException( - 'Impersonation not enabled for this workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const targetHasAdminPrivileges = - toImpersonateUserWorkspace.user.canImpersonate === true || - toImpersonateUserWorkspace.user.canAccessFullAdminPanel === true; - - const impersonatorHasAdminPrivileges = - impersonatorUserWorkspace.user.canImpersonate === true || - impersonatorUserWorkspace.user.canAccessFullAdminPanel === true; - - if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) { - throw new AuthException( - 'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + throw new AuthException(message, exceptionCode, { userFriendlyMessage }); } return this.generateImpersonationLoginToken( impersonatorUserWorkspace, toImpersonateUserWorkspace, - 'workspace', + authorizationResult.level, ); } diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts new file mode 100644 index 0000000000..b0d0de5a2d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts @@ -0,0 +1,18 @@ +import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util'; + +describe('userCanServerImpersonate', () => { + it('should be true only when canImpersonate is true', () => { + expect( + userCanServerImpersonate({ + canImpersonate: true, + canAccessFullAdminPanel: false, + }), + ).toBe(true); + expect( + userCanServerImpersonate({ + canImpersonate: false, + canAccessFullAdminPanel: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts new file mode 100644 index 0000000000..1b3323013c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts @@ -0,0 +1,30 @@ +import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util'; + +describe('userHasAdminPrivileges', () => { + it('should be true when canImpersonate is true', () => { + expect( + userHasAdminPrivileges({ + canImpersonate: true, + canAccessFullAdminPanel: false, + }), + ).toBe(true); + }); + + it('should be true when canAccessFullAdminPanel is true', () => { + expect( + userHasAdminPrivileges({ + canImpersonate: false, + canAccessFullAdminPanel: true, + }), + ).toBe(true); + }); + + it('should be false when neither privilege is set', () => { + expect( + userHasAdminPrivileges({ + canImpersonate: false, + canAccessFullAdminPanel: false, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts new file mode 100644 index 0000000000..5105219c39 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts @@ -0,0 +1,18 @@ +import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util'; + +describe('userIsFullAdmin', () => { + it('should be true only when canAccessFullAdminPanel is true', () => { + expect( + userIsFullAdmin({ + canAccessFullAdminPanel: true, + canImpersonate: false, + }), + ).toBe(true); + expect( + userIsFullAdmin({ + canAccessFullAdminPanel: false, + canImpersonate: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts new file mode 100644 index 0000000000..a0a26e0ecd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts @@ -0,0 +1,4 @@ +export type ImpersonationAuthorizationUser = { + canImpersonate: boolean; + canAccessFullAdminPanel: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts new file mode 100644 index 0000000000..b3fb1c934d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts @@ -0,0 +1,4 @@ +export { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type'; +export { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util'; +export { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util'; +export { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util'; diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts new file mode 100644 index 0000000000..6905e035a0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts @@ -0,0 +1,5 @@ +import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type'; + +export const userCanServerImpersonate = ( + user: ImpersonationAuthorizationUser, +): boolean => user.canImpersonate === true; diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts new file mode 100644 index 0000000000..3f2051c136 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts @@ -0,0 +1,7 @@ +import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type'; +import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util'; +import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util'; + +export const userHasAdminPrivileges = ( + user: ImpersonationAuthorizationUser, +): boolean => userCanServerImpersonate(user) || userIsFullAdmin(user); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts new file mode 100644 index 0000000000..076e79c335 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts @@ -0,0 +1,5 @@ +import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type'; + +export const userIsFullAdmin = ( + user: ImpersonationAuthorizationUser, +): boolean => user.canAccessFullAdminPanel === true; diff --git a/packages/twenty-server/src/engine/guards/admin-panel-guard.ts b/packages/twenty-server/src/engine/guards/admin-panel-guard.ts index 52a951a8b6..542cd6d526 100644 --- a/packages/twenty-server/src/engine/guards/admin-panel-guard.ts +++ b/packages/twenty-server/src/engine/guards/admin-panel-guard.ts @@ -3,6 +3,8 @@ import { GqlExecutionContext } from '@nestjs/graphql'; import { type Observable } from 'rxjs'; +import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util'; + export class AdminPanelGuard implements CanActivate { canActivate( context: ExecutionContext, @@ -10,6 +12,6 @@ export class AdminPanelGuard implements CanActivate { const ctx = GqlExecutionContext.create(context); const request = ctx.getContext().req; - return request.user.canAccessFullAdminPanel === true; + return userIsFullAdmin(request.user); } } diff --git a/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts index 141d859c5e..d1663d6385 100644 --- a/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts +++ b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts @@ -1,6 +1,8 @@ import { type CanActivate, type ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util'; + // Read-only admin-panel lookups (user/recent-users search) are available to // full admins as well as impersonators: managing server-admin access requires // finding users, and a full admin is the higher privilege. @@ -9,9 +11,6 @@ export class AdminPanelOrImpersonateGuard implements CanActivate { const ctx = GqlExecutionContext.create(context); const request = ctx.getContext().req; - return ( - request.user.canAccessFullAdminPanel === true || - request.user.canImpersonate === true - ); + return userHasAdminPrivileges(request.user); } } diff --git a/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts index 9994043776..171af3ba6a 100644 --- a/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts +++ b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts @@ -9,6 +9,7 @@ import { msg } from '@lingui/core/macro'; import { isDefined } from 'class-validator'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util'; import { PermissionsException, PermissionsExceptionCode, @@ -36,7 +37,9 @@ export class ImpersonatePermissionGuard implements CanActivate { ); } - if (request.user.canImpersonate === true) return true; + if (userCanServerImpersonate(request.user)) { + return true; + } const hasPermission = await this.permissionsService.userHasWorkspaceSettingPermission({ diff --git a/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts b/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts index 872a035b55..80301dc875 100644 --- a/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts +++ b/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts @@ -1,11 +1,13 @@ import { type CanActivate, type ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util'; + export class ServerLevelImpersonateGuard implements CanActivate { canActivate(context: ExecutionContext): boolean | Promise { const ctx = GqlExecutionContext.create(context); const request = ctx.getContext().req; - return request.user.canImpersonate === true; + return userCanServerImpersonate(request.user); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts index 306e747e8b..43b8b8b909 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { PermissionFlagType } from 'twenty-shared/constants'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { DataSource, Repository } from 'typeorm'; @@ -9,6 +10,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; +import { RolePermissionFlagService } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.service'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; @@ -41,6 +43,7 @@ export class DevSeederPermissionsService { private readonly roleRepository: WorkspaceScopedRepository, private readonly fieldPermissionService: FieldPermissionService, private readonly roleTargetService: RoleTargetService, + private readonly rolePermissionFlagService: RolePermissionFlagService, @InjectDataSource() private readonly coreDataSource: DataSource, ) {} @@ -126,6 +129,18 @@ export class DevSeederPermissionsService { userWorkspaceIds: [limitedUserWorkspaceId], roleId: limitedRole.id, }); + + const impersonateOnlyRole = + await this.createImpersonateOnlyRoleForSeedWorkspace({ + workspaceId, + ownerFlatApplication: workspaceCustomFlatApplication, + }); + + await this.userRoleService.assignRoleToManyUserWorkspace({ + workspaceId, + userWorkspaceIds: [USER_WORKSPACE_DATA_SEED_IDS.SCOTT], + roleId: impersonateOnlyRole.id, + }); } } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME; @@ -184,6 +199,44 @@ export class DevSeederPermissionsService { return memberRole; } + // Creates a non-admin role whose only elevated capability is the workspace + // IMPERSONATE permission flag. Assigned to Scott so the impersonation + // escalation guard can be exercised: a non-admin holding IMPERSONATE must + // still be blocked from impersonating an admin. + private async createImpersonateOnlyRoleForSeedWorkspace({ + ownerFlatApplication, + workspaceId, + }: { + workspaceId: string; + ownerFlatApplication: FlatApplication; + }): Promise { + const impersonateOnlyRole = await this.roleService.createRole({ + ownerFlatApplication, + workspaceId, + input: { + label: 'Impersonate-only', + description: 'Member role granted only the impersonate permission', + icon: 'IconSpy', + canUpdateAllSettings: false, + canAccessAllTools: false, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + }, + }); + + await this.rolePermissionFlagService.upsertPermissionFlags({ + workspaceId, + input: { + roleId: impersonateOnlyRole.id, + permissionFlagKeys: [PermissionFlagType.IMPERSONATE], + }, + }); + + return impersonateOnlyRole; + } + private async createLimitedRoleForSeedWorkspace({ ownerFlatApplication, workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts new file mode 100644 index 0000000000..ec805c3d92 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts @@ -0,0 +1,63 @@ +import { type QueryRunner } from 'typeorm'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; + +const tableName = 'twoFactorAuthenticationMethod'; + +export const TWO_FACTOR_AUTHENTICATION_METHOD_DATA_SEED_IDS = { + JANE: '20202020-1111-4a01-8001-000000000004', +}; + +type SeedTwoFactorAuthenticationMethodsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; + // Real enc:v2 envelope encrypted for this workspace so the value survives the + // secret-encryption rotation command (which decrypts/re-encrypts every + // `totp-secret` row); a malformed placeholder would break that rotation. + encryptedSecret: string; +}; + +// Seeds a verified TOTP method for Jane so the server-level impersonation flow +// (which requires verified 2FA outside development) can be tested end to end. +// Gated to the test environment and Apple workspace: only the VERIFIED status +// is read by the impersonation check, and seeding it in the dev/demo workspace +// would otherwise make Jane unable to complete a real 2FA login. +export const seedTwoFactorAuthenticationMethods = async ({ + queryRunner, + schemaName, + workspaceId, + encryptedSecret, +}: SeedTwoFactorAuthenticationMethodsArgs) => { + if ( + process.env.NODE_ENV !== 'test' || + workspaceId !== SEED_APPLE_WORKSPACE_ID + ) { + return; + } + + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, [ + 'id', + 'workspaceId', + 'userWorkspaceId', + 'secret', + 'status', + 'strategy', + ]) + .orIgnore() + .values([ + { + id: TWO_FACTOR_AUTHENTICATION_METHOD_DATA_SEED_IDS.JANE, + workspaceId, + userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE, + secret: encryptedSecret, + status: 'VERIFIED', + strategy: 'TOTP', + }, + ]) + .execute(); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts index c786a390e9..1784d34a4e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts @@ -19,6 +19,7 @@ export const USER_WORKSPACE_DATA_SEED_IDS = { TIM_ACME: '20202020-e10a-4c27-a90b-b08c57b02d44', JONY_ACME: '20202020-e10a-4c27-a90b-b08c57b02d45', PHIL_ACME: '20202020-e10a-4c27-a90b-b08c57b02d46', + SCOTT: '20202020-1111-4a01-8001-000000000002', }; const { @@ -66,6 +67,11 @@ export const seedUserWorkspaces = async ({ userId: USER_DATA_SEED_IDS.PHIL, workspaceId, }, + { + id: USER_WORKSPACE_DATA_SEED_IDS.SCOTT, + userId: USER_DATA_SEED_IDS.SCOTT, + workspaceId, + }, ]; userWorkspaces = [...originalUserWorkspaces, ...randomUserWorkspaces]; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts index cc902b234d..2ca53d47ad 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts @@ -9,6 +9,7 @@ export const USER_DATA_SEED_IDS = { TIM: '20202020-9e3b-46d4-a556-88b9ddc2b034', JONY: '20202020-3957-4908-9c36-2929a23f8357', PHIL: '20202020-7169-42cf-bc47-1cfef15264b8', + SCOTT: '20202020-1111-4a01-8001-000000000001', }; const { users: randomUsers, userIds: randomUserIds } = generateRandomUsers(); @@ -66,6 +67,17 @@ export const seedUsers = async ({ queryRunner, schemaName }: SeedUsersArgs) => { canAccessFullAdminPanel: true, isEmailVerified: true, }, + { + id: USER_DATA_SEED_IDS.SCOTT, + firstName: 'Scott', + lastName: 'Forstall', + email: 'scott.forstall@apple.dev', + passwordHash: + '$2b$10$3LwXjJRtLsfx4hLuuXhxt.3mWgismTiZFCZSG3z9kDrSfsrBl0fT6', // tim@apple.dev + canImpersonate: false, + canAccessFullAdminPanel: false, + isEmailVerified: true, + }, ]; const allUsers = [...originalUsers, ...randomUsers]; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts index 8c56651c4b..8fadf15cb9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts @@ -31,6 +31,7 @@ export const WORKSPACE_MEMBER_DATA_SEED_IDS = { JONY: '20202020-77d5-4cb6-b60a-f4a835a85d61', PHIL: '20202020-1553-45c6-a028-5a9064cce07f', JANE: '20202020-463f-435b-828c-107e007a2711', + SCOTT: '20202020-1111-4a01-8001-000000000003', }; const { @@ -79,6 +80,20 @@ const originalWorkspaceMembers: WorkspaceMemberDataSeed[] = [ }, ]; +// Scott only belongs to the Apple workspace (he has no YCombinator +// user-workspace), so he must never leak into other workspaces' member seeds. +const appleOnlyWorkspaceMembers: WorkspaceMemberDataSeed[] = [ + { + id: WORKSPACE_MEMBER_DATA_SEED_IDS.SCOTT, + nameFirstName: 'Scott', + nameLastName: 'Forstall', + locale: 'en', + colorScheme: 'Light', + userEmail: 'scott.forstall@apple.dev', + userId: USER_DATA_SEED_IDS.SCOTT, + }, +]; + export const WORKSPACE_MEMBER_DATA_SEEDS: WorkspaceMemberDataSeed[] = [ ...originalWorkspaceMembers, ...randomWorkspaceMembers, @@ -88,13 +103,16 @@ export const getWorkspaceMemberDataSeeds = ( workspaceId: string, ): WorkspaceMemberDataSeed[] => { // In test environment, only return original members to avoid conflicts + // (Scott is appended for Apple to back the impersonation escalation test). if (process.env.NODE_ENV === 'test') { - return originalWorkspaceMembers; + return workspaceId === SEED_APPLE_WORKSPACE_ID + ? [...originalWorkspaceMembers, ...appleOnlyWorkspaceMembers] + : originalWorkspaceMembers; } if (workspaceId === SEED_APPLE_WORKSPACE_ID) { - // Apple workspace gets all workspace members (original + random) - return WORKSPACE_MEMBER_DATA_SEEDS; + // Apple workspace gets all workspace members (original + random + Scott) + return [...WORKSPACE_MEMBER_DATA_SEEDS, ...appleOnlyWorkspaceMembers]; } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { // YC workspace gets all 4 original workspace members return originalWorkspaceMembers; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts index 66666bb0bd..671424d4bf 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts @@ -7,6 +7,7 @@ import { ApplicationRegistrationModule } from 'src/engine/core-modules/applicati import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; @@ -14,6 +15,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module'; +import { RolePermissionFlagModule } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.module'; import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; @@ -40,6 +42,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceCacheStorageModule, TypeORMModule, RoleModule, + RolePermissionFlagModule, RoleTargetModule, UserRoleModule, ApiKeyModule, @@ -55,6 +58,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceMigrationModule, TwentyStandardApplicationModule, SdkClientModule, + SecretEncryptionModule, UpgradeModule, ], exports: [DevSeederService], diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index 56409f35a5..24999287e2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -7,6 +7,8 @@ import { v4 } from 'uuid'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; @@ -33,6 +35,7 @@ import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/u import { seedMetadataEntities } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-metadata-entities.util'; import { seedPageLayouts } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layouts.util'; import { seedServerId } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-server-id.util'; +import { seedTwoFactorAuthenticationMethods } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util'; import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; import { createWorkspace } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util'; @@ -64,6 +67,7 @@ export class DevSeederService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly prefillFrontComponentService: PrefillFrontComponentService, private readonly prefillLogicFunctionService: PrefillLogicFunctionService, + private readonly secretEncryptionService: SecretEncryptionService, @InjectDataSource() private readonly coreDataSource: DataSource, @InjectRepository(WorkspaceEntity) @@ -310,6 +314,15 @@ export class DevSeederService { await seedServerId({ queryRunner, schemaName }); await seedUsers({ queryRunner, schemaName }); await seedUserWorkspaces({ queryRunner, schemaName, workspaceId }); + await seedTwoFactorAuthenticationMethods({ + queryRunner, + schemaName, + workspaceId, + encryptedSecret: this.secretEncryptionService.encryptVersioned( + 'seed-totp-secret-test-fixture' as PlaintextString, + { workspaceId }, + ), + }); await this.applicationService.createTwentyStandardApplication( { diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap new file mode 100644 index 0000000000..0b1e4f792b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Impersonation - access token validation denial (integration) rejects an invalid impersonation access token on a protected query 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FORBIDDEN_EXCEPTION", + "userFriendlyMessage": { + "id": Any, + "message": "You do not have permission to impersonate users in this workspace.", + }, + }, + "message": "Impersonation not allowed", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap new file mode 100644 index 0000000000..79451de49e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Impersonation - self-impersonation denial (integration) rejects a user impersonating themselves 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FORBIDDEN_EXCEPTION", + "userFriendlyMessage": "You do not have permission to perform this action.", + }, + "message": "User cannot impersonate themselves", + "name": "ForbiddenError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap new file mode 100644 index 0000000000..7e8fff138e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Impersonation - unauthenticated request denial (integration) rejects an impersonation request with no authentication 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Forbidden resource", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts new file mode 100644 index 0000000000..ebb8fad2bc --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +// Exercises the per-request JWT validation checkpoint (jwt.auth.strategy): +// an impersonation access token whose impersonator is not authorized to +// impersonate the target must be rejected on every protected query. +describe('Impersonation - access token validation denial (integration)', () => { + it('rejects an invalid impersonation access token on a protected query', async () => { + const query = gql` + query People { + people { + edges { + node { + id + } + } + } + } + `; + + const response = await makeGraphqlAPIRequest( + { query }, + APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN, + ); + + expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts new file mode 100644 index 0000000000..44272897b2 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts @@ -0,0 +1,18 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { impersonate } from 'test/integration/graphql/utils/impersonate.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +describe('Impersonation - self-impersonation denial (integration)', () => { + it('rejects a user impersonating themselves', async () => { + const { errors } = await impersonate({ + userId: USER_DATA_SEED_IDS.JANE, + workspaceId: SEED_APPLE_WORKSPACE_ID, + accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts new file mode 100644 index 0000000000..2faaae18a7 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts @@ -0,0 +1,32 @@ +import request from 'supertest'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +const SERVER_URL = `http://localhost:${APP_PORT}`; + +describe('Impersonation - unauthenticated request denial (integration)', () => { + it('rejects an impersonation request with no authentication', async () => { + const response = await request(SERVER_URL) + .post('/metadata') + .send({ + query: ` + mutation Impersonate($userId: UUID!, $workspaceId: UUID!) { + impersonate(userId: $userId, workspaceId: $workspaceId) { + loginToken { + token + } + } + } + `, + variables: { + userId: USER_DATA_SEED_IDS.JONY, + workspaceId: SEED_APPLE_WORKSPACE_ID, + }, + }) + .expect(200); + + expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap new file mode 100644 index 0000000000..abbcb2641a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Server-level impersonation - authorization denials (integration) rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FORBIDDEN_EXCEPTION", + "userFriendlyMessage": "Server-level impersonation is not enabled for this user or workspace.", + }, + "message": "Server level impersonation not allowed", + "name": "ForbiddenError", +} +`; + +exports[`Server-level impersonation - authorization denials (integration) rejects cross-workspace impersonation when the impersonator has no verified 2FA 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED", + "userFriendlyMessage": "Set up two-factor authentication before impersonating users in another workspace.", + }, + "message": "Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.", + "name": "ForbiddenError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts new file mode 100644 index 0000000000..942c13c7cf --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts @@ -0,0 +1,40 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util'; +import { impersonate } from 'test/integration/graphql/utils/impersonate.util'; + +import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +// Jony has the server-level canImpersonate capability but no verified 2FA +// method. Outside development, server-level (cross-workspace) impersonation +// requires verified 2FA, so the attempt to impersonate Tim in the YCombinator +// workspace must be denied at the token-generation checkpoint. +const IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN = APPLE_JONY_MEMBER_ACCESS_TOKEN; + +describe('Server-level impersonation - authorization denials (integration)', () => { + it('rejects cross-workspace impersonation when the impersonator has no verified 2FA', async () => { + const { errors } = await impersonate({ + userId: USER_DATA_SEED_IDS.TIM, + workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID, + accessToken: IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); + + it('rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces', async () => { + const scottAccessToken = await getAccessTokenForCredentials({ + email: 'scott.forstall@apple.dev', + }); + + const { errors } = await impersonate({ + userId: USER_DATA_SEED_IDS.JONY, + workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID, + accessToken: scottAccessToken, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts new file mode 100644 index 0000000000..cf9e46f3d1 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts @@ -0,0 +1,58 @@ +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { impersonate } from 'test/integration/graphql/utils/impersonate.util'; + +import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +// Jane (authenticated in Apple) can impersonate across workspaces because she +// has the server-level canImpersonate capability and a seeded verified 2FA +// method. The target, Tim, is impersonated in the YCombinator workspace. +const SERVER_IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN; + +describe('Server-level impersonation - successful flow (integration)', () => { + it('lets a server admin with verified 2FA impersonate a user in another workspace', async () => { + const { data: impersonateData, errors: impersonateErrors } = + await impersonate({ + userId: USER_DATA_SEED_IDS.TIM, + workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID, + accessToken: SERVER_IMPERSONATOR_ACCESS_TOKEN, + expectToFail: false, + }); + + expect(impersonateErrors).toBeUndefined(); + + const loginToken = impersonateData.impersonate.loginToken.token; + const origin = impersonateData.impersonate.workspace.workspaceUrls + .subdomainUrl as string; + + expect(loginToken).toBeDefined(); + expect(impersonateData.impersonate.workspace.id).toBe( + SEED_YCOMBINATOR_WORKSPACE_ID, + ); + + const { data: tokensData, errors: tokensErrors } = + await getAuthTokensFromLoginToken({ + loginToken, + origin, + expectToFail: false, + }); + + expect(tokensErrors).toBeUndefined(); + + const impersonationAccessToken = + tokensData.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token; + + expect(impersonationAccessToken).toBeDefined(); + + const { data: currentUserData, errors: currentUserErrors } = + await getCurrentUser({ + accessToken: impersonationAccessToken, + expectToFail: false, + }); + + expect(currentUserErrors).toBeUndefined(); + expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.TIM); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap new file mode 100644 index 0000000000..46bfeadae1 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Workspace-level impersonation - authorization denials (integration) rejects a non-admin with the impersonate permission impersonating an admin 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FORBIDDEN_EXCEPTION", + "userFriendlyMessage": "Only administrators can impersonate users with admin privileges.", + }, + "message": "Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.", + "name": "ForbiddenError", +} +`; + +exports[`Workspace-level impersonation - authorization denials (integration) rejects a user without the impersonate permission 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "PERMISSION_DENIED", + "userFriendlyMessage": "User does not have permission.", + }, + "message": "Entity performing the request does not have permission", + "name": "ForbiddenError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts new file mode 100644 index 0000000000..a515443368 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts @@ -0,0 +1,44 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util'; +import { impersonate } from 'test/integration/graphql/utils/impersonate.util'; +import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +describe('Workspace-level impersonation - authorization denials (integration)', () => { + it('rejects a user without the impersonate permission', async () => { + const memberWithoutPermissionAccessToken = + await signUpInWorkspaceAndGetAccessToken( + 'impersonation-no-permission@apple.dev', + ); + + const { errors } = await impersonate({ + userId: USER_DATA_SEED_IDS.JONY, + workspaceId: SEED_APPLE_WORKSPACE_ID, + accessToken: memberWithoutPermissionAccessToken, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); + + // Scott is a seeded non-admin Apple member (canImpersonate / + // canAccessFullAdminPanel both false) whose role grants only the IMPERSONATE + // permission flag. He passes the impersonate permission guard but must still + // be blocked from impersonating an admin (Jony) by the escalation check. + it('rejects a non-admin with the impersonate permission impersonating an admin', async () => { + const scottAccessToken = await getAccessTokenForCredentials({ + email: 'scott.forstall@apple.dev', + }); + + const { errors } = await impersonate({ + userId: USER_DATA_SEED_IDS.JONY, + workspaceId: SEED_APPLE_WORKSPACE_ID, + accessToken: scottAccessToken, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts new file mode 100644 index 0000000000..e4346d8c35 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts @@ -0,0 +1,57 @@ +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { impersonate } from 'test/integration/graphql/utils/impersonate.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +// Jane holds the Apple admin role (so she has the IMPERSONATE workspace +// permission). Scott is a non-admin Apple member (canImpersonate / +// canAccessFullAdminPanel both false), so this exercises the member-only +// workspace impersonation path: an admin impersonating a regular member, with +// the admin-target escalation guard correctly not triggering. +const IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN; + +describe('Workspace-level impersonation - successful flow (integration)', () => { + it('lets a workspace admin impersonate a member and obtain a usable access token', async () => { + const { data: impersonateData, errors: impersonateErrors } = + await impersonate({ + userId: USER_DATA_SEED_IDS.SCOTT, + workspaceId: SEED_APPLE_WORKSPACE_ID, + accessToken: IMPERSONATOR_ACCESS_TOKEN, + expectToFail: false, + }); + + expect(impersonateErrors).toBeUndefined(); + + const loginToken = impersonateData.impersonate.loginToken.token; + const origin = impersonateData.impersonate.workspace.workspaceUrls + .subdomainUrl as string; + + expect(loginToken).toBeDefined(); + + const { data: tokensData, errors: tokensErrors } = + await getAuthTokensFromLoginToken({ + loginToken, + origin, + expectToFail: false, + }); + + expect(tokensErrors).toBeUndefined(); + + const impersonationAccessToken = + tokensData.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token; + + expect(impersonationAccessToken).toBeDefined(); + + const { data: currentUserData, errors: currentUserErrors } = + await getCurrentUser({ + accessToken: impersonationAccessToken, + expectToFail: false, + }); + + expect(currentUserErrors).toBeUndefined(); + expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.SCOTT); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts deleted file mode 100644 index b8f5dbd197..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 - 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', - ); - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts new file mode 100644 index 0000000000..7826a1356c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts @@ -0,0 +1,67 @@ +import request from 'supertest'; +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; + +const SERVER_URL = `http://localhost:${APP_PORT}`; + +const buildAppleOrigin = (): string => { + const origin = new URL(SERVER_URL); + + origin.hostname = + process.env.IS_MULTIWORKSPACE_ENABLED === 'true' + ? `apple.${origin.hostname}` + : origin.hostname; + + return origin.toString(); +}; + +type GetAccessTokenForCredentialsArgs = { + email: string; + password?: string; +}; + +// Logs a seeded Apple user in with email/password and returns a usable +// access token, minting it dynamically rather than relying on a pre-baked +// entry in test-tokens.json. +export const getAccessTokenForCredentials = async ({ + email, + password = 'tim@apple.dev', +}: GetAccessTokenForCredentialsArgs): Promise => { + const origin = buildAppleOrigin(); + + const loginResponse = await request(SERVER_URL) + .post('/metadata') + .set('Origin', origin) + .send({ + query: ` + mutation GetLoginTokenFromCredentials( + $email: String! + $password: String! + $origin: String! + ) { + getLoginTokenFromCredentials( + email: $email + password: $password + origin: $origin + ) { + loginToken { + token + } + } + } + `, + variables: { email, password, origin }, + }) + .expect(200); + + const loginToken = + loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token; + + const { data } = await getAuthTokensFromLoginToken({ + loginToken, + origin, + expectToFail: false, + }); + + return data.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken + .token; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts b/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts new file mode 100644 index 0000000000..2f2e255764 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts @@ -0,0 +1,65 @@ +import gql from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type ImpersonateDTO } from 'src/engine/core-modules/admin-panel/dtos/impersonate.dto'; + +type ImpersonateUtilArgs = { + userId: string; + workspaceId: string; + accessToken: string; + expectToFail?: boolean; +}; + +export const impersonate = async ({ + userId, + workspaceId, + accessToken, + expectToFail, +}: ImpersonateUtilArgs): CommonResponseBody<{ + impersonate: ImpersonateDTO; +}> => { + const mutation = gql` + mutation Impersonate($userId: UUID!, $workspaceId: UUID!) { + impersonate(userId: $userId, workspaceId: $workspaceId) { + loginToken { + token + expiresAt + } + workspace { + id + workspaceUrls { + subdomainUrl + customUrl + } + } + } + } + `; + + const response = await makeMetadataAPIRequest( + { + query: mutation, + variables: { userId, workspaceId }, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Impersonate should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Impersonate has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};