Centralize and standardize impersonation validation rules (#21717)

# Introduction
Followup https://github.com/twentyhq/twenty/pull/21707

## Behavioral change worth calling out
Server-level impersonation now requires verified 2FA outside development
at every checkpoint (generation, exchange, and per-request). In main the
2FA gate only existed in ImpersonationService. This is the right
tightening, but it means existing server-admin impersonation sessions in
production for admins without verified 2FA will now be rejected on the
next request, not just at token creation.

cc @s0yd4RK

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21717?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: s0yd4RK <285671363+s0yd4RK@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paul Rastoin
2026-06-18 13:13:50 +02:00
committed by GitHub
parent 465eb05aaf
commit e7e99247e8
50 changed files with 1510 additions and 269 deletions
@@ -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,
@@ -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: {},
@@ -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}`,
});
@@ -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', () => {
@@ -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<UserWorkspaceEntity>,
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 {
@@ -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,
@@ -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:
@@ -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>(
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' });
});
});
});
@@ -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,
),
);
});
@@ -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.`,
},
};
@@ -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}`,
};
@@ -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 {}
@@ -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,
@@ -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) {}
@@ -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<ImpersonationAuthorizationResult> {
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;
}
}
@@ -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<UserWorkspaceEntity>,
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,
);
}
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -0,0 +1,4 @@
export type ImpersonationAuthorizationUser = {
canImpersonate: boolean;
canAccessFullAdminPanel: boolean;
};
@@ -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';
@@ -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;
@@ -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);
@@ -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;