Skip to content

Commit e7e9924

Browse files
prastoins0yd4RKcursoragent
authored
Centralize and standardize impersonation validation rules (#21717)
# Introduction Followup #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>
1 parent 465eb05 commit e7e9924

50 files changed

Lines changed: 1506 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/twenty-server/src/engine/core-modules/auth/auth.module.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33

4+
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
45
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
56
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
6-
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
77
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
88
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
9+
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
910
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
10-
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
1111
import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller';
12+
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
1213
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
13-
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
1414
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
1515
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
1616
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
4040
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
4141
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
4242
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
43+
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
4344
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
4445
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
4546
import { FileModule } from 'src/engine/core-modules/file/file.module';
4647
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
48+
import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module';
4749
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
4850
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
4951
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
@@ -59,7 +61,6 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
5961
import { UserModule } from 'src/engine/core-modules/user/user.module';
6062
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
6163
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
62-
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
6364
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
6465
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
6566
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
6869
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
6970
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
7071
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
71-
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
7272
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
73+
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
7374
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
7475
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
7576

@@ -109,6 +110,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
109110
WorkspaceInvitationModule,
110111
EmailVerificationModule,
111112
GuardRedirectModule,
113+
ImpersonationAuthorizationModule,
112114
MetricsModule,
113115
PermissionsModule,
114116
TwoFactorAuthenticationModule,

packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
55
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
66
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
77
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
8+
import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
89
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
910
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
1011
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
@@ -120,6 +121,10 @@ describe('AuthResolver', () => {
120121
provide: EmailVerificationTokenService,
121122
useValue: {},
122123
},
124+
{
125+
provide: ImpersonationAuthorizationService,
126+
useValue: {},
127+
},
123128
{
124129
provide: PermissionsService,
125130
useValue: {},

packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts

Lines changed: 30 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ import { EmailVerificationService } from 'src/engine/core-modules/email-verifica
6060
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
6161
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
6262
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
63+
import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant';
64+
import { IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant';
65+
import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
6366
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
6467
import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input';
6568
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.
8083
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
8184
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
8285
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
83-
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
8486
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
8587

8688
import { ApiKeyToken } from './dto/api-key-token.dto';
@@ -132,7 +134,7 @@ export class AuthResolver {
132134
private emailVerificationTokenService: EmailVerificationTokenService,
133135
private ssoService: SSOService,
134136
private readonly eventLogEmitterService: EventLogEmitterService,
135-
private readonly permissionsService: PermissionsService,
137+
private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
136138
private readonly subdomainManagerService: SubdomainManagerService,
137139
) {}
138140

@@ -702,7 +704,7 @@ export class AuthResolver {
702704
const impersonatorUserWorkspace =
703705
await this.userWorkspaceRepository.findOne({
704706
where: { id: impersonatorUserWorkspaceId },
705-
relations: ['user', 'workspace'],
707+
relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'],
706708
});
707709

708710
const toImpersonateUserWorkspace =
@@ -733,75 +735,49 @@ export class AuthResolver {
733735
);
734736
}
735737

736-
const isServerLevelImpersonation =
737-
toImpersonateUserWorkspace.workspace.id !==
738-
impersonatorUserWorkspace.workspace.id;
739-
740738
const eventLogContext = this.eventLogEmitterService.createContext({
741739
workspaceId: workspace.id,
742740
userId: impersonatorUserWorkspace.user.id,
743741
});
744742

743+
const impersonationLevel =
744+
this.impersonationAuthorizationService.getImpersonationLevel(
745+
impersonatorUserWorkspace,
746+
toImpersonateUserWorkspace,
747+
);
748+
745749
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
746-
level: isServerLevelImpersonation ? 'server' : 'workspace',
750+
level: impersonationLevel,
747751
action: 'token_exchange_attempt',
748752
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
749753
});
750754

751-
const hasServerLevelImpersonatePermission =
752-
impersonatorUserWorkspace.user.canImpersonate === true &&
753-
toImpersonateUserWorkspace.workspace.allowImpersonation === true;
754-
755-
if (isServerLevelImpersonation) {
756-
if (!hasServerLevelImpersonatePermission) {
757-
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
758-
level: 'server',
759-
action: 'token_exchange_failed',
760-
message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
761-
});
762-
763-
throw new AuthException(
764-
'Server level impersonation not allowed on this workspace',
765-
AuthExceptionCode.FORBIDDEN_EXCEPTION,
766-
);
767-
}
755+
const authorizationResult =
756+
await this.impersonationAuthorizationService.checkImpersonationAuthorization(
757+
impersonatorUserWorkspace,
758+
toImpersonateUserWorkspace,
759+
);
768760

761+
if (!authorizationResult.allowed) {
769762
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
770-
level: 'server',
771-
action: 'token_exchange_success',
772-
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
763+
level: authorizationResult.level,
764+
action: 'token_exchange_failed',
765+
message: IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON[
766+
authorizationResult.reason
767+
]({
768+
targetUserEmail,
769+
impersonatorUserId: impersonatorUserWorkspace.user.id,
770+
}),
773771
});
774772

775-
return {
776-
workspaceId: workspace.id,
777-
impersonatorUserWorkspaceId: impersonatorUserWorkspace.id,
778-
impersonatedUserWorkspaceId: toImpersonateUserWorkspace.id,
779-
impersonatorUserId: impersonatorUserWorkspace.user.id,
780-
impersonatedUserId: toImpersonateUserWorkspace.user.id,
781-
};
782-
}
783-
784-
const hasWorkspaceLevelImpersonatePermission =
785-
await this.permissionsService.userHasWorkspaceSettingPermission({
786-
userWorkspaceId: impersonatorUserWorkspace.id,
787-
setting: PermissionFlagType.IMPERSONATE,
788-
workspaceId: workspace.id,
789-
});
773+
const { message, exceptionCode, userFriendlyMessage } =
774+
IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason];
790775

791-
if (!hasWorkspaceLevelImpersonatePermission) {
792-
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
793-
level: 'workspace',
794-
action: 'token_exchange_failed',
795-
message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
796-
});
797-
throw new AuthException(
798-
'Impersonation not allowed',
799-
AuthExceptionCode.FORBIDDEN_EXCEPTION,
800-
);
776+
throw new AuthException(message, exceptionCode, { userFriendlyMessage });
801777
}
802778

803779
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
804-
level: 'workspace',
780+
level: authorizationResult.level,
805781
action: 'token_exchange_success',
806782
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
807783
});

packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
} from 'src/engine/core-modules/auth/auth.exception';
99
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
1010
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
11+
import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
12+
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
1113
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
1214

1315
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -17,6 +19,7 @@ describe('JwtAuthStrategy', () => {
1719
let userWorkspaceRepository: any;
1820
let jwtWrapperService: any;
1921
let permissionsService: any;
22+
let twentyConfigService: any;
2023
let workspaceCacheService: any;
2124
let coreEntityCacheService: any;
2225

@@ -52,6 +55,12 @@ describe('JwtAuthStrategy', () => {
5255
userHasWorkspaceSettingPermission: jest.fn(),
5356
};
5457

58+
twentyConfigService = {
59+
get: jest.fn((key: string) =>
60+
key === 'NODE_ENV' ? NodeEnvironment.DEVELOPMENT : undefined,
61+
),
62+
};
63+
5564
workspaceCacheService = {
5665
getOrRecompute: jest.fn(
5766
async (workspaceId: string, cacheKeys: string[]) => {
@@ -118,9 +127,12 @@ describe('JwtAuthStrategy', () => {
118127
new JwtAuthStrategy(
119128
jwtWrapperService,
120129
userWorkspaceRepository,
121-
permissionsService,
122130
workspaceCacheService,
123131
coreEntityCacheService,
132+
new ImpersonationAuthorizationService(
133+
permissionsService,
134+
twentyConfigService,
135+
),
124136
);
125137

126138
describe('API_KEY validation', () => {

0 commit comments

Comments
 (0)