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:
@@ -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}`,
|
||||
});
|
||||
|
||||
+13
-1
@@ -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', () => {
|
||||
|
||||
+20
-57
@@ -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,
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+411
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-6
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
+47
@@ -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.`,
|
||||
},
|
||||
};
|
||||
+23
@@ -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}`,
|
||||
};
|
||||
+11
@@ -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 {}
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+9
-1
@@ -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) {}
|
||||
|
||||
|
||||
+129
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
-83
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -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);
|
||||
});
|
||||
});
|
||||
+30
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
@@ -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';
|
||||
+5
@@ -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;
|
||||
+7
@@ -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);
|
||||
+5
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<boolean> {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const request = ctx.getContext().req;
|
||||
|
||||
return request.user.canImpersonate === true;
|
||||
return userCanServerImpersonate(request.user);
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -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<RoleEntity>,
|
||||
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<RoleDTO> {
|
||||
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,
|
||||
|
||||
+63
@@ -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();
|
||||
};
|
||||
+6
@@ -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];
|
||||
|
||||
+12
@@ -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];
|
||||
|
||||
+21
-3
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
+13
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user