diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 6f2bd3d2bc..21780e6745 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command'; import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command'; +import { SecretEncryptionRotationModule } from 'src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module'; import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command'; import { InstallPreInstalledAppsCommand } from 'src/database/commands/install-pre-installed-apps.command'; import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service'; @@ -85,6 +86,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au WorkspaceCacheModule, WorkspaceVersionModule, UpgradeModule, + SecretEncryptionRotationModule, ], providers: [ DataSeedWorkspaceCommand, diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts new file mode 100644 index 0000000000..c9344dcc0f --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts @@ -0,0 +1,12 @@ +export const SECRET_ENCRYPTION_ROTATION_SITE_NAME = { + CONNECTED_ACCOUNT_ACCESS_TOKEN: 'connected-account-access-token', + CONNECTED_ACCOUNT_REFRESH_TOKEN: 'connected-account-refresh-token', + APPLICATION_VARIABLE: 'application-variable', + APPLICATION_REGISTRATION_VARIABLE: 'application-registration-variable', + SIGNING_KEY_PRIVATE_KEY: 'signing-key-private-key', + SENSITIVE_CONFIG_STORAGE: 'sensitive-config-storage', + TOTP_SECRET: 'totp-secret', +} as const; + +export type SecretEncryptionRotationSiteName = + (typeof SECRET_ENCRYPTION_ROTATION_SITE_NAME)[keyof typeof SECRET_ENCRYPTION_ROTATION_SITE_NAME]; diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts new file mode 100644 index 0000000000..33de8a3d38 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts @@ -0,0 +1,183 @@ +import { Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; +import { + type ObjectLiteral, + type Repository, + type SelectQueryBuilder, +} from 'typeorm'; + +import { type SecretEncryptionRotationSiteName } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant'; +import { + SecretEncryptionRotationHandler, + type SecretEncryptionRotationContext, + type SecretEncryptionRotationOutcome, +} from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface'; +import { buildCurrentEncryptionKeyIdEnvelopeLikePattern } from 'src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util'; +import { buildRotationErrorMessage } from 'src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; + +const ZERO_UUID = '00000000-0000-0000-0000-000000000000'; + +type EntityWithId = ObjectLiteral & { id: string }; + +export type ColumnRotationSiteConfig = { + siteName: SecretEncryptionRotationSiteName; + repository: Repository; + encryptedColumn: keyof Entity & string; + isWorkspaceScoped?: boolean; + extraWhere?: Partial; +}; + +export class ColumnRotationSiteHandler< + Entity extends EntityWithId = EntityWithId, +> extends SecretEncryptionRotationHandler { + readonly siteName: SecretEncryptionRotationSiteName; + private readonly logger = new Logger(ColumnRotationSiteHandler.name); + + constructor( + private readonly config: ColumnRotationSiteConfig, + private readonly secretEncryptionService: SecretEncryptionService, + ) { + super(); + this.siteName = config.siteName; + } + + async countRemaining({ + currentEncryptionKeyId, + }: { + currentEncryptionKeyId: string; + }): Promise { + const currentEnvelopePattern = + buildCurrentEncryptionKeyIdEnvelopeLikePattern(currentEncryptionKeyId); + + return this.applyExtraWhere( + this.config.repository.createQueryBuilder('row'), + ) + .andWhere(`row.${this.config.encryptedColumn} NOT LIKE :p`, { + p: currentEnvelopePattern, + }) + .getCount(); + } + + async rotate({ + currentEncryptionKeyId, + batchSize, + dryRun, + }: SecretEncryptionRotationContext): Promise { + const outcome: SecretEncryptionRotationOutcome = { + rotated: 0, + skipped: 0, + errors: 0, + }; + const currentEnvelopePattern = + buildCurrentEncryptionKeyIdEnvelopeLikePattern(currentEncryptionKeyId); + let cursor = ZERO_UUID; + + while (true) { + const rows = await this.applyExtraWhere( + this.config.repository.createQueryBuilder('row'), + ) + .andWhere('row.id > :cursor', { cursor }) + .andWhere(`row.${this.config.encryptedColumn} NOT LIKE :p`, { + p: currentEnvelopePattern, + }) + .orderBy('row.id', 'ASC') + .take(batchSize) + .getMany(); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const rowOutcome = await this.rotateRow({ row, dryRun }); + + outcome.rotated += rowOutcome.rotated; + outcome.skipped += rowOutcome.skipped; + outcome.errors += rowOutcome.errors; + } + + cursor = rows[rows.length - 1].id; + } + + return outcome; + } + + private async rotateRow({ + row, + dryRun, + }: { + row: Entity; + dryRun: boolean; + }): Promise { + const { encryptedColumn } = this.config; + const rowId = row.id; + const currentValue = row[encryptedColumn] as string | null | undefined; + + if ( + !isDefined(currentValue) || + !currentValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) + ) { + this.logger.error( + `[${this.siteName}] row ${rowId}: column '${encryptedColumn}' is not a versioned envelope (expected '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}…'), refusing to rotate.`, + ); + + return { rotated: 0, skipped: 0, errors: 1 }; + } + + const cryptoOptions = this.config.isWorkspaceScoped + ? { workspaceId: row.workspaceId as string } + : undefined; + + try { + const plaintext = this.secretEncryptionService.decryptVersioned( + currentValue, + cryptoOptions, + ); + const reEncrypted = this.secretEncryptionService.encryptVersioned( + plaintext, + cryptoOptions, + ); + + if (!dryRun) { + const setValues = { [encryptedColumn]: reEncrypted } as Partial; + const updateResult = await this.config.repository + .createQueryBuilder() + .update() + .set(setValues) + .where('id = :rowId', { rowId }) + .andWhere(`"${encryptedColumn}" = :currentValue`, { currentValue }) + .execute(); + + if ((updateResult.affected ?? 0) === 0) { + return { rotated: 0, skipped: 1, errors: 0 }; + } + } + + return { rotated: 1, skipped: 0, errors: 0 }; + } catch (error) { + this.logger.error(buildRotationErrorMessage(this.siteName, rowId, error)); + + return { rotated: 0, skipped: 0, errors: 1 }; + } + } + + private applyExtraWhere( + qb: SelectQueryBuilder, + ): SelectQueryBuilder { + if (!isDefined(this.config.extraWhere)) { + return qb; + } + + for (const [column, value] of Object.entries(this.config.extraWhere)) { + const parameterKey = `extra_${column}`; + qb.andWhere(`row.${column} = :${parameterKey}`, { + [parameterKey]: value, + }); + } + + return qb; + } +} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts new file mode 100644 index 0000000000..3a94870814 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts @@ -0,0 +1,196 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository, type SelectQueryBuilder } from 'typeorm'; + +import { SECRET_ENCRYPTION_ROTATION_SITE_NAME } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant'; +import { + SecretEncryptionRotationHandler, + type SecretEncryptionRotationContext, + type SecretEncryptionRotationOutcome, +} from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface'; +import { buildRotationErrorMessage } from 'src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util'; +import { + KeyValuePairEntity, + KeyValuePairType, +} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; +import { type ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; +import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +@Injectable() +export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotationHandler { + readonly siteName = + SECRET_ENCRYPTION_ROTATION_SITE_NAME.SENSITIVE_CONFIG_STORAGE; + private readonly logger = new Logger( + SensitiveConfigStorageRotationHandler.name, + ); + + constructor( + @InjectRepository(KeyValuePairEntity) + private readonly keyValuePairRepository: Repository, + private readonly secretEncryptionService: SecretEncryptionService, + ) { + super(); + } + + async countRemaining({ + currentEncryptionKeyId, + }: { + currentEncryptionKeyId: string; + }): Promise { + const sensitiveStringConfigKeys = this.collectSensitiveStringConfigKeys(); + + if (sensitiveStringConfigKeys.length === 0) { + return 0; + } + + return this.buildRotationQuery({ + currentEncryptionKeyId, + sensitiveStringConfigKeys, + }).getCount(); + } + + async rotate({ + currentEncryptionKeyId, + batchSize, + dryRun, + }: SecretEncryptionRotationContext): Promise { + const sensitiveStringConfigKeys = this.collectSensitiveStringConfigKeys(); + + if (sensitiveStringConfigKeys.length === 0) { + return { rotated: 0, skipped: 0, errors: 0 }; + } + + const outcome: SecretEncryptionRotationOutcome = { + rotated: 0, + skipped: 0, + errors: 0, + }; + let cursor = '00000000-0000-0000-0000-000000000000'; + + while (true) { + const rows = await this.buildRotationQuery({ + currentEncryptionKeyId, + sensitiveStringConfigKeys, + }) + .andWhere('kvp.id > :cursor', { cursor }) + .orderBy('kvp.id', 'ASC') + .take(batchSize) + .getMany(); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const rowOutcome = await this.rotateRow({ row, dryRun }); + + outcome.rotated += rowOutcome.rotated; + outcome.skipped += rowOutcome.skipped; + outcome.errors += rowOutcome.errors; + } + + cursor = rows[rows.length - 1].id; + } + + return outcome; + } + + private async rotateRow({ + row, + dryRun, + }: { + row: KeyValuePairEntity; + dryRun: boolean; + }): Promise { + const rawValue = row.value as unknown; + + if ( + !isNonEmptyString(rawValue) || + !rawValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) + ) { + this.logger.error( + `[${this.siteName}] row ${row.id} (config key '${row.key}'): value is not a versioned envelope (expected '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}…'), refusing to rotate.`, + ); + + return { rotated: 0, skipped: 0, errors: 1 }; + } + + try { + const plaintext = this.secretEncryptionService.decryptVersioned(rawValue); + const reEncrypted = + this.secretEncryptionService.encryptVersioned(plaintext); + + if (!dryRun) { + const updateResult = await this.keyValuePairRepository + .createQueryBuilder() + .update() + .set({ value: reEncrypted as never }) + .where('id = :id', { id: row.id }) + .andWhere('CAST(value AS text) = :originalValueText', { + originalValueText: JSON.stringify(rawValue), + }) + .execute(); + + if ((updateResult.affected ?? 0) === 0) { + return { rotated: 0, skipped: 1, errors: 0 }; + } + } + + return { rotated: 1, skipped: 0, errors: 0 }; + } catch (error) { + this.logger.error( + buildRotationErrorMessage(this.siteName, row.id, error), + ); + + return { rotated: 0, skipped: 0, errors: 1 }; + } + } + + private buildRotationQuery({ + currentEncryptionKeyId, + sensitiveStringConfigKeys, + }: { + currentEncryptionKeyId: string; + sensitiveStringConfigKeys: string[]; + }): SelectQueryBuilder { + const currentEnvelopePattern = `"${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}${currentEncryptionKeyId}:%"`; + + return this.keyValuePairRepository + .createQueryBuilder('kvp') + .where('kvp.type = :type', { type: KeyValuePairType.CONFIG_VARIABLE }) + .andWhere('kvp.userId IS NULL') + .andWhere('kvp.workspaceId IS NULL') + .andWhere('kvp.key IN (:...sensitiveStringConfigKeys)', { + sensitiveStringConfigKeys, + }) + .andWhere('CAST(kvp.value AS text) NOT LIKE :current', { + current: currentEnvelopePattern, + }); + } + + private collectSensitiveStringConfigKeys(): string[] { + const metadata = TypedReflect.getMetadata( + 'config-variables', + ConfigVariables.prototype.constructor, + ) as ConfigVariablesMetadataMap | undefined; + + if (!isDefined(metadata)) { + return []; + } + + return Object.entries(metadata) + .filter( + ([, descriptor]) => + descriptor?.isSensitive === true && + descriptor?.type === ConfigVariableType.STRING, + ) + .map(([configKey]) => configKey); + } +} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts new file mode 100644 index 0000000000..62e5b0a6e2 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts @@ -0,0 +1,32 @@ +import { type SecretEncryptionRotationSiteName } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant'; + +export type SecretEncryptionRotationContext = { + currentEncryptionKeyId: string; + batchSize: number; + dryRun: boolean; +}; + +export type SecretEncryptionRotationOutcome = { + rotated: number; + skipped: number; + errors: number; +}; + +export type SecretEncryptionRotationSiteResult = + SecretEncryptionRotationOutcome & { + siteName: SecretEncryptionRotationSiteName; + remainingBefore: number; + durationMs: number; + }; + +export abstract class SecretEncryptionRotationHandler { + abstract readonly siteName: SecretEncryptionRotationSiteName; + + abstract countRemaining(args: { + currentEncryptionKeyId: string; + }): Promise; + + abstract rotate( + context: SecretEncryptionRotationContext, + ): Promise; +} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts new file mode 100644 index 0000000000..1be743e0d6 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts @@ -0,0 +1,89 @@ +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { CommandLogger } from 'src/database/commands/logger'; +import { SecretEncryptionRotationRunnerService } from 'src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service'; + +const DEFAULT_BATCH_SIZE = 200; +const MAX_BATCH_SIZE = 5000; + +type RotateSecretEncryptionCommandOptions = { + site?: string; + batchSize?: number; + dryRun?: boolean; +}; + +@Command({ + name: 'secret-encryption:rotate', + description: + 'Re-encrypts every at-rest secret stored in an enc:v2 envelope using the current ENCRYPTION_KEY. Idempotent: rows already on the current key are skipped via a SQL filter, so the command is safe to interrupt and re-run. Requires FALLBACK_ENCRYPTION_KEY to be set to the previous key when rotating to a fresh ENCRYPTION_KEY.', +}) +export class RotateSecretEncryptionCommand extends CommandRunner { + protected logger: CommandLogger; + + constructor( + private readonly secretEncryptionRotationRunnerService: SecretEncryptionRotationRunnerService, + ) { + super(); + this.logger = new CommandLogger({ + verbose: false, + constructorName: this.constructor.name, + }); + } + + @Option({ + flags: '-s, --site ', + description: + 'Limit rotation to a single site. Omit to run all sites. Known sites: connected-account-tokens, application-variable, application-registration-variable, signing-key-private-keys, sensitive-config-storage, totp-secrets.', + required: false, + }) + parseSite(value: string): string { + return value; + } + + @Option({ + flags: '-b, --batch-size ', + description: `Number of rows fetched per batch (default ${DEFAULT_BATCH_SIZE}, capped at ${MAX_BATCH_SIZE}).`, + required: false, + }) + parseBatchSize(value: string): number { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid --batch-size value: ${value}`); + } + + return Math.min(parsed, MAX_BATCH_SIZE); + } + + @Option({ + flags: '-d, --dry-run', + description: + 'Decrypt + re-encrypt rows in memory but skip the UPDATE. Useful for sizing a rotation before pulling the trigger.', + required: false, + }) + parseDryRun(): boolean { + return true; + } + + override async run( + _passedParams: string[], + options: RotateSecretEncryptionCommandOptions, + ): Promise { + const summary = await this.secretEncryptionRotationRunnerService.run({ + site: options.site, + batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE, + dryRun: options.dryRun ?? false, + }); + + const totalErrors = summary.results.reduce( + (sum, result) => sum + result.errors, + 0, + ); + + if (totalErrors > 0) { + throw new Error( + `secret-encryption:rotate completed with ${totalErrors} error(s) — see logs above.`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts new file mode 100644 index 0000000000..02c502126d --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SensitiveConfigStorageRotationHandler } from 'src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler'; +import { RotateSecretEncryptionCommand } from 'src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command'; +import { SecretEncryptionRotationRunnerService } from 'src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; +import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity'; +import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; +import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; + +@Module({ + imports: [ + SecretEncryptionModule, + TwentyConfigModule, + TypeOrmModule.forFeature([ + ApplicationRegistrationVariableEntity, + ApplicationVariableEntity, + ConnectedAccountEntity, + KeyValuePairEntity, + SigningKeyEntity, + TwoFactorAuthenticationMethodEntity, + ]), + ], + providers: [ + SensitiveConfigStorageRotationHandler, + SecretEncryptionRotationRunnerService, + RotateSecretEncryptionCommand, + ], + exports: [ + SecretEncryptionRotationRunnerService, + RotateSecretEncryptionCommand, + ], +}) +export class SecretEncryptionRotationModule {} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts new file mode 100644 index 0000000000..0ae28726e6 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { performance } from 'perf_hooks'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { + SECRET_ENCRYPTION_ROTATION_SITE_NAME, + type SecretEncryptionRotationSiteName, +} from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant'; +import { ColumnRotationSiteHandler } from 'src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler'; +import { SensitiveConfigStorageRotationHandler } from 'src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler'; +import { + SecretEncryptionRotationHandler, + type SecretEncryptionRotationSiteResult, +} from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; +import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { computeEncryptionKeyId } from 'src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util'; +import { resolveEncryptionKeysOrThrow } from 'src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util'; +import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; +import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; + +export type RotationRunOptions = { + site?: SecretEncryptionRotationSiteName | string; + batchSize: number; + dryRun: boolean; +}; + +export type RotationRunSummary = { + currentEncryptionKeyId: string; + fallbackEncryptionKeyId: string | null; + results: SecretEncryptionRotationSiteResult[]; + totalDurationMs: number; +}; + +@Injectable() +export class SecretEncryptionRotationRunnerService { + private readonly logger = new Logger( + SecretEncryptionRotationRunnerService.name, + ); + + private readonly handlersBySiteName: Map< + SecretEncryptionRotationSiteName, + SecretEncryptionRotationHandler + >; + + constructor( + private readonly environmentConfigDriver: EnvironmentConfigDriver, + secretEncryptionService: SecretEncryptionService, + sensitiveConfigStorageRotationHandler: SensitiveConfigStorageRotationHandler, + @InjectRepository(ApplicationRegistrationVariableEntity) + applicationRegistrationVariableRepository: Repository, + @InjectRepository(ApplicationVariableEntity) + applicationVariableRepository: Repository, + @InjectRepository(ConnectedAccountEntity) + connectedAccountRepository: Repository, + @InjectRepository(SigningKeyEntity) + signingKeyRepository: Repository, + @InjectRepository(TwoFactorAuthenticationMethodEntity) + twoFactorAuthenticationMethodRepository: Repository, + ) { + const handlers: SecretEncryptionRotationHandler[] = [ + new ColumnRotationSiteHandler( + { + siteName: + SECRET_ENCRYPTION_ROTATION_SITE_NAME.CONNECTED_ACCOUNT_ACCESS_TOKEN, + repository: connectedAccountRepository, + encryptedColumn: 'accessToken', + isWorkspaceScoped: true, + }, + secretEncryptionService, + ), + new ColumnRotationSiteHandler( + { + siteName: + SECRET_ENCRYPTION_ROTATION_SITE_NAME.CONNECTED_ACCOUNT_REFRESH_TOKEN, + repository: connectedAccountRepository, + encryptedColumn: 'refreshToken', + isWorkspaceScoped: true, + }, + secretEncryptionService, + ), + new ColumnRotationSiteHandler( + { + siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_VARIABLE, + repository: applicationVariableRepository, + encryptedColumn: 'value', + isWorkspaceScoped: true, + extraWhere: { isSecret: true }, + }, + secretEncryptionService, + ), + new ColumnRotationSiteHandler( + { + siteName: + SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_REGISTRATION_VARIABLE, + repository: applicationRegistrationVariableRepository, + encryptedColumn: 'encryptedValue', + }, + secretEncryptionService, + ), + new ColumnRotationSiteHandler( + { + siteName: + SECRET_ENCRYPTION_ROTATION_SITE_NAME.SIGNING_KEY_PRIVATE_KEY, + repository: signingKeyRepository, + encryptedColumn: 'privateKey', + }, + secretEncryptionService, + ), + new ColumnRotationSiteHandler( + { + siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.TOTP_SECRET, + repository: twoFactorAuthenticationMethodRepository, + encryptedColumn: 'secret', + isWorkspaceScoped: true, + }, + secretEncryptionService, + ), + sensitiveConfigStorageRotationHandler, + ]; + + this.handlersBySiteName = new Map( + handlers.map((handler) => [handler.siteName, handler]), + ); + } + + listSiteNames(): SecretEncryptionRotationSiteName[] { + return Array.from(this.handlersBySiteName.keys()); + } + + async run(options: RotationRunOptions): Promise { + const { primary: currentEncryptionKey, fallback: fallbackEncryptionKey } = + resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); + const currentEncryptionKeyId = computeEncryptionKeyId({ + rawKey: currentEncryptionKey, + }); + const fallbackEncryptionKeyId = isDefined(fallbackEncryptionKey) + ? computeEncryptionKeyId({ rawKey: fallbackEncryptionKey }) + : null; + + this.logger.log( + `[secret-encryption:rotate] current encryption key id: ${currentEncryptionKeyId}${ + options.dryRun ? ' (dry-run)' : '' + }`, + ); + + if (isDefined(fallbackEncryptionKeyId)) { + this.logger.log( + `[secret-encryption:rotate] fallback encryption key id: ${fallbackEncryptionKeyId}`, + ); + } else { + this.logger.warn( + '[secret-encryption:rotate] FALLBACK_ENCRYPTION_KEY is not set — rows encrypted under a previous ENCRYPTION_KEY cannot be decrypted by this command. Set FALLBACK_ENCRYPTION_KEY to the previous ENCRYPTION_KEY before running rotation.', + ); + } + + const handlersToRun = this.resolveHandlersToRun(options.site); + + const startedAt = performance.now(); + const results: SecretEncryptionRotationSiteResult[] = []; + + for (const handler of handlersToRun) { + const siteStartedAt = performance.now(); + + const remainingBefore = await handler.countRemaining({ + currentEncryptionKeyId, + }); + + this.logger.log( + `[${handler.siteName}] start: ${remainingBefore} row(s) need rotation`, + ); + + const { rotated, skipped, errors } = await handler.rotate({ + currentEncryptionKeyId, + batchSize: options.batchSize, + dryRun: options.dryRun, + }); + + const durationMs = Math.round(performance.now() - siteStartedAt); + const result: SecretEncryptionRotationSiteResult = { + siteName: handler.siteName, + remainingBefore, + rotated, + skipped, + errors, + durationMs, + }; + + results.push(result); + + this.logger.log( + `[${handler.siteName}] DONE in ${durationMs}ms — rotated=${rotated} skipped=${skipped} errors=${errors}`, + ); + } + + const totalDurationMs = Math.round(performance.now() - startedAt); + + this.logSummary({ + currentEncryptionKeyId, + fallbackEncryptionKeyId, + results, + totalDurationMs, + }); + + return { + currentEncryptionKeyId, + fallbackEncryptionKeyId, + results, + totalDurationMs, + }; + } + + private resolveHandlersToRun( + site: string | undefined, + ): SecretEncryptionRotationHandler[] { + if (!isDefined(site)) { + return Array.from(this.handlersBySiteName.values()); + } + + const handler = this.handlersBySiteName.get( + site as SecretEncryptionRotationSiteName, + ); + + if (!isDefined(handler)) { + throw new Error( + `Unknown rotation site: '${site}'. Known sites: ${this.listSiteNames().join( + ', ', + )}.`, + ); + } + + return [handler]; + } + + private logSummary(summary: RotationRunSummary): void { + const totalRotated = summary.results.reduce( + (sum, result) => sum + result.rotated, + 0, + ); + const totalSkipped = summary.results.reduce( + (sum, result) => sum + result.skipped, + 0, + ); + const totalErrors = summary.results.reduce( + (sum, result) => sum + result.errors, + 0, + ); + + this.logger.log('[secret-encryption:rotate] summary'); + + for (const result of summary.results) { + this.logger.log( + ` ${result.siteName.padEnd(36)} rotated=${result.rotated} skipped=${result.skipped} errors=${result.errors} (${result.durationMs}ms)`, + ); + } + + this.logger.log( + `[secret-encryption:rotate] all sites complete in ${summary.totalDurationMs}ms — rotated=${totalRotated} skipped=${totalSkipped} errors=${totalErrors}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util.ts new file mode 100644 index 0000000000..7734e48403 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util.ts @@ -0,0 +1,6 @@ +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; + +export const buildCurrentEncryptionKeyIdEnvelopeLikePattern = ( + currentEncryptionKeyId: string, +): string => + `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}${currentEncryptionKeyId}:%`; diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util.ts new file mode 100644 index 0000000000..dbbcd6f375 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util.ts @@ -0,0 +1,21 @@ +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; + +export const buildRotationErrorMessage = ( + siteName: string, + rowId: string, + error: unknown, +): string => { + if ( + error instanceof SecretEncryptionException && + error.code === SecretEncryptionExceptionCode.UNKNOWN_KEY_ID + ) { + return `[${siteName}] row ${rowId}: ${error.message} The row is encrypted with a key that is neither ENCRYPTION_KEY nor FALLBACK_ENCRYPTION_KEY — set FALLBACK_ENCRYPTION_KEY to the key that produced this envelope (e.g. after a partial earlier rotation).`; + } + + const detail = error instanceof Error ? error.message : String(error); + + return `[${siteName}] row ${rowId}: failed to re-encrypt: ${detail}`; +}; diff --git a/packages/twenty-server/test/integration/secret-encryption/secret-encryption-rotation.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/secret-encryption-rotation.integration-spec.ts new file mode 100644 index 0000000000..52c767dc4a --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/secret-encryption-rotation.integration-spec.ts @@ -0,0 +1,128 @@ +import crypto from 'crypto'; + +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { + findOneApplicationIdByUniversalIdentifier, + findOneApplicationVariables, +} from 'test/integration/secret-encryption/utils/find-one-application.util'; +import { runSecretEncryptionRotationCommand } from 'test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util'; +import { updateOneApplicationVariable } from 'test/integration/secret-encryption/utils/update-one-application-variable.util'; + +import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant'; + +const ROTATION_VARIABLE_KEY = 'TEST_ROTATION_SECRET'; + +const buildExpectedMask = (plaintext: string): string => { + const visibleCharsCount = Math.min(5, Math.floor(plaintext.length / 10)); + + return `${plaintext.slice(0, visibleCharsCount)}${SECRET_APPLICATION_VARIABLE_MASK}`; +}; + +describe('secret-encryption:rotate command (integration)', () => { + let applicationUniversalIdentifier: string; + let applicationId: string; + const plaintext = 'secret-value-that-must-survive-key-rotation'; + + beforeAll(async () => { + applicationUniversalIdentifier = crypto.randomUUID(); + const roleUniversalIdentifier = crypto.randomUUID(); + const roleLabel = `Rotation Test Role ${crypto.randomUUID()}`; + + await setupApplicationForSync({ + applicationUniversalIdentifier, + name: 'Rotation Test Application', + description: 'Verifies secret-encryption:rotate keeps secrets readable', + sourcePath: 'test-secret-encryption-rotation', + }); + + await syncApplication({ + manifest: buildBaseManifest({ + appId: applicationUniversalIdentifier, + roleId: roleUniversalIdentifier, + overrides: { + application: { + universalIdentifier: applicationUniversalIdentifier, + defaultRoleUniversalIdentifier: roleUniversalIdentifier, + displayName: 'Rotation Test Application', + description: + 'Verifies secret-encryption:rotate keeps secrets readable', + applicationVariables: { + [ROTATION_VARIABLE_KEY]: { + universalIdentifier: crypto.randomUUID(), + isSecret: true, + }, + }, + packageJsonChecksum: null, + yarnLockChecksum: null, + }, + roles: [ + { + universalIdentifier: roleUniversalIdentifier, + label: roleLabel, + description: 'A role for the secret encryption rotation test', + }, + ], + }, + }), + expectToFail: false, + }); + + applicationId = await findOneApplicationIdByUniversalIdentifier({ + universalIdentifier: applicationUniversalIdentifier, + }); + + await updateOneApplicationVariable({ + key: ROTATION_VARIABLE_KEY, + value: plaintext, + applicationId, + }); + }, 120000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier, + }); + }); + + it( + 'keeps the secret applicationVariable decryptable via GraphQL after running the rotation', + async () => { + await runSecretEncryptionRotationCommand(); + + const variables = await findOneApplicationVariables({ + id: applicationId, + }); + const variable = variables.find( + (applicationVariable) => + applicationVariable.key === ROTATION_VARIABLE_KEY, + ); + + expect(variable).toBeDefined(); + expect(variable?.isSecret).toBe(true); + expect(variable?.value).toBe(buildExpectedMask(plaintext)); + }, + 60000, + ); + + it( + 'is idempotent: running rotation twice does not corrupt secrets', + async () => { + await runSecretEncryptionRotationCommand(); + await runSecretEncryptionRotationCommand(); + + const variables = await findOneApplicationVariables({ + id: applicationId, + }); + const variable = variables.find( + (applicationVariable) => + applicationVariable.key === ROTATION_VARIABLE_KEY, + ); + + expect(variable?.value).toBe(buildExpectedMask(plaintext)); + }, + 90000, + ); +}); diff --git a/packages/twenty-server/test/integration/secret-encryption/utils/find-one-application.util.ts b/packages/twenty-server/test/integration/secret-encryption/utils/find-one-application.util.ts new file mode 100644 index 0000000000..102c0f6655 --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/utils/find-one-application.util.ts @@ -0,0 +1,66 @@ +import gql from 'graphql-tag'; +import { isDefined } from 'twenty-shared/utils'; + +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +export type ApplicationVariableSummary = { + key: string; + value: string; + isSecret: boolean; +}; + +export const findOneApplicationIdByUniversalIdentifier = async ({ + universalIdentifier, +}: { + universalIdentifier: string; +}): Promise => { + const response = await makeMetadataAPIRequest({ + query: gql` + query FindOneApplicationIdByUniversalIdentifier( + $universalIdentifier: UUID! + ) { + findOneApplication(universalIdentifier: $universalIdentifier) { + id + } + } + `, + variables: { universalIdentifier }, + }); + + const id: string | undefined = response.body?.data?.findOneApplication?.id; + + if (!isDefined(id)) { + throw new Error( + `findOneApplication did not return an id for universalIdentifier=${universalIdentifier}: ${JSON.stringify( + response.body, + )}`, + ); + } + + return id; +}; + +export const findOneApplicationVariables = async ({ + id, +}: { + id: string; +}): Promise => { + const response = await makeMetadataAPIRequest({ + query: gql` + query FindOneApplicationVariables($id: UUID!) { + findOneApplication(id: $id) { + applicationVariables { + key + value + isSecret + } + } + } + `, + variables: { id }, + }); + + expect(response.body.errors).toBeUndefined(); + + return response.body.data.findOneApplication.applicationVariables; +}; diff --git a/packages/twenty-server/test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util.ts b/packages/twenty-server/test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util.ts new file mode 100644 index 0000000000..11b87807a8 --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util.ts @@ -0,0 +1,74 @@ +import { spawn } from 'child_process'; +import path from 'path'; + +const TWENTY_SERVER_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +const COMMAND_JS_PATH = path.join( + TWENTY_SERVER_ROOT, + 'dist', + 'command', + 'command.js', +); + +type RotateArguments = { + site?: string; + batchSize?: number; + dryRun?: boolean; +}; + +const buildArgs = ({ site, batchSize, dryRun }: RotateArguments): string[] => { + const args = ['secret-encryption:rotate']; + + if (site !== undefined) { + args.push('--site', site); + } + if (batchSize !== undefined) { + args.push('--batch-size', String(batchSize)); + } + if (dryRun === true) { + args.push('--dry-run'); + } + + return args; +}; + +export const runSecretEncryptionRotationCommand = async ( + args: RotateArguments = {}, +): Promise => { + await new Promise((resolve, reject) => { + const child = spawn('node', [COMMAND_JS_PATH, ...buildArgs(args)], { + cwd: TWENTY_SERVER_ROOT, + env: { ...process.env, NODE_ENV: 'test' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + reject( + new Error( + `Failed to spawn secret-encryption:rotate: ${error.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ), + ); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `secret-encryption:rotate exited with code ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ), + ); + }); + }); +}; diff --git a/packages/twenty-server/test/integration/secret-encryption/utils/update-one-application-variable.util.ts b/packages/twenty-server/test/integration/secret-encryption/utils/update-one-application-variable.util.ts new file mode 100644 index 0000000000..a0f7eec849 --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/utils/update-one-application-variable.util.ts @@ -0,0 +1,32 @@ +import gql from 'graphql-tag'; + +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +export const updateOneApplicationVariable = async ({ + key, + value, + applicationId, +}: { + key: string; + value: string; + applicationId: string; +}): Promise => { + const response = await makeMetadataAPIRequest({ + query: gql` + mutation UpdateOneApplicationVariable( + $key: String! + $value: String! + $applicationId: UUID! + ) { + updateOneApplicationVariable( + key: $key + value: $value + applicationId: $applicationId + ) + } + `, + variables: { key, value, applicationId }, + }); + + expect(response.body.errors).toBeUndefined(); +}; diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index 27b932c2db..37355fa586 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -49,11 +49,7 @@ export const createApp = async ( const stripeSDKMockService = new StripeSDKMockService(); const mockExceptionHandlerService = new ExceptionHandlerMockService(); let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({ - imports: [ - AppModule, - JobsModule, - MessageQueueModule.registerExplorer(), - ], + imports: [AppModule, JobsModule, MessageQueueModule.registerExplorer()], providers: [ { provide: APP_FILTER,