From edf2bfbf76ea27c796ec8a229760c44228d7a2f4 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 21 May 2026 19:31:52 +0200 Subject: [PATCH] feat(server): rotate connectedAccount.connectionParameters via secret-encryption:rotate (#20807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a new `connected-account-connection-parameters` site to the `secret-encryption:rotate` CLI introduced in #20613, so the nested password envelopes inside `connectedAccount.connectionParameters` (IMAP / SMTP / CALDAV — encrypted at-rest in #20673) are re-encrypted under the current `ENCRYPTION_KEY` alongside every other at-rest secret site. Without this, rotating `ENCRYPTION_KEY` on a 2.7+ instance would silently leave IMAP / SMTP / CalDav passwords on the old key id. ### Why a new handler A dedicated handler is required (rather than reusing `ColumnRotationSiteHandler`) because the envelope lives at `connectionParameters->''->>'password'`, not in the whole column, and up to three independent envelopes may need rotating per row. The handler: - Uses the same cursor-based, idempotent, online pattern as the existing handlers, with a SQL predicate that skips rows where every non-null protocol password is already on the current key id. - Threads \`workspaceId\` into HKDF, matching how \`EncryptConnectionParametersSlowInstanceCommand\` backfilled. - Rebuilds only the protocols whose passwords are not yet current, so a partial mid-row failure cannot cause unnecessary re-encryption on resume. - Guards the UPDATE with jsonb-level deep equality (\`IS NOT DISTINCT FROM CAST(:json AS jsonb)\`) so optimistic concurrency is unaffected by Postgres's internal jsonb key ordering vs. JSON.stringify ordering. - Refuses to rotate plaintext passwords (counted as \`errors\`) — operators must finish the 2.7 slow instance command (\`EncryptConnectionParametersSlowInstanceCommand\`) before running rotation. ### Sites covered (now) | Site | Location | Scope | | --- | --- | --- | | \`connected-account-access-token\` | \`connectedAccount.accessToken\` | workspace | | \`connected-account-refresh-token\` | \`connectedAccount.refreshToken\` | workspace | | **\`connected-account-connection-parameters\`** (new) | \`connectedAccount.connectionParameters.{IMAP,SMTP,CALDAV}.password\` | workspace | | \`application-variable\` | \`applicationVariable.value\` (isSecret) | workspace | | \`application-registration-variable\` | \`applicationRegistrationVariable.encryptedValue\` | instance | | \`signing-key-private-key\` | \`signingKey.privateKey\` | instance | | \`totp-secret\` | \`twoFactorAuthenticationMethod.secret\` | workspace | | \`sensitive-config-storage\` | \`keyValuePair.value\` (sensitive STRING configs) | instance | --- ...-encryption-rotation-site-name.constant.ts | 2 + .../connection-parameters-rotation.handler.ts | 196 ++++++++++++++++++ .../rotate-secret-encryption.command.ts | 8 +- .../secret-encryption-rotation.module.ts | 2 + ...cret-encryption-rotation-runner.service.ts | 3 + ...on-parameters-rotation.integration-spec.ts | 177 ++++++++++++++++ 6 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts create mode 100644 packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts 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 index c9344dcc0f2..4f4671d92f5 100644 --- 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 @@ -1,6 +1,8 @@ export const SECRET_ENCRYPTION_ROTATION_SITE_NAME = { CONNECTED_ACCOUNT_ACCESS_TOKEN: 'connected-account-access-token', CONNECTED_ACCOUNT_REFRESH_TOKEN: 'connected-account-refresh-token', + CONNECTED_ACCOUNT_CONNECTION_PARAMETERS: + 'connected-account-connection-parameters', APPLICATION_VARIABLE: 'application-variable', APPLICATION_REGISTRATION_VARIABLE: 'application-registration-variable', SIGNING_KEY_PRIVATE_KEY: 'signing-key-private-key', diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts new file mode 100644 index 00000000000..cc452bd4f2b --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts @@ -0,0 +1,196 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { ACCOUNT_TYPES } from 'twenty-shared/constants'; +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 { 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 { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; + +const ZERO_UUID = '00000000-0000-0000-0000-000000000000'; + +@Injectable() +export class ConnectionParametersRotationHandler extends SecretEncryptionRotationHandler { + readonly siteName = + SECRET_ENCRYPTION_ROTATION_SITE_NAME.CONNECTED_ACCOUNT_CONNECTION_PARAMETERS; + private readonly logger = new Logger( + ConnectionParametersRotationHandler.name, + ); + + constructor( + @InjectRepository(ConnectedAccountEntity) + private readonly connectedAccountRepository: Repository, + private readonly secretEncryptionService: SecretEncryptionService, + ) { + super(); + } + + async countRemaining({ + currentEncryptionKeyId, + }: { + currentEncryptionKeyId: string; + }): Promise { + return this.buildRowToSelectQuery({ currentEncryptionKeyId }).getCount(); + } + + async rotate({ + currentEncryptionKeyId, + batchSize, + dryRun, + }: SecretEncryptionRotationContext): Promise { + const outcome: SecretEncryptionRotationOutcome = { + rotated: 0, + skipped: 0, + errors: 0, + }; + let cursor = ZERO_UUID; + + while (true) { + const rows = await this.buildRowToSelectQuery({ currentEncryptionKeyId }) + .andWhere('connected_account.id > :cursor', { cursor }) + .orderBy('connected_account.id', 'ASC') + .take(batchSize) + .getMany(); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const originalConnectionParameters = row.connectionParameters; + + if (!isDefined(originalConnectionParameters)) { + outcome.skipped += 1; + continue; + } + + let reEncryptedConnectionParameters: ImapSmtpCaldavParams; + + try { + reEncryptedConnectionParameters = + this.reEncryptConnectionParametersOrThrow({ + connectionParameters: originalConnectionParameters, + workspaceId: row.workspaceId, + }); + } catch (error) { + this.logger.error( + buildRotationErrorMessage(this.siteName, row.id, error), + ); + outcome.errors += 1; + continue; + } + + if (dryRun) { + outcome.rotated += 1; + continue; + } + + const updateResult = await this.connectedAccountRepository + .createQueryBuilder() + .update() + .set({ connectionParameters: reEncryptedConnectionParameters }) + .where('id = :rowId', { rowId: row.id }) + .andWhere( + '"connectionParameters" IS NOT DISTINCT FROM CAST(:originalConnectionParametersJson AS jsonb)', + { + originalConnectionParametersJson: JSON.stringify( + originalConnectionParameters, + ), + }, + ) + .execute(); + + if ((updateResult.affected ?? 0) === 0) { + outcome.skipped += 1; + continue; + } + + outcome.rotated += 1; + } + + cursor = rows[rows.length - 1].id; + } + + return outcome; + } + + private reEncryptConnectionParametersOrThrow({ + connectionParameters, + workspaceId, + }: { + connectionParameters: ImapSmtpCaldavParams; + workspaceId: string; + }): ImapSmtpCaldavParams { + const result: ImapSmtpCaldavParams = { ...connectionParameters }; + + for (const protocol of ACCOUNT_TYPES) { + const params = connectionParameters[protocol]; + + if (!isDefined(params)) { + continue; + } + + // Refuse non-enc:v2 values up front: decryptVersioned would otherwise + // fall through to unauthenticated legacy CTR and corrupt the password. + if (!params.password.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)) { + throw new SecretEncryptionException( + `${protocol} password is not a versioned envelope (expected '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}…'), refusing to rotate.`, + SecretEncryptionExceptionCode.MALFORMED_ENVELOPE, + ); + } + + const plaintext = this.secretEncryptionService.decryptVersioned( + params.password, + { workspaceId }, + ); + + result[protocol] = { + ...params, + password: this.secretEncryptionService.encryptVersioned(plaintext, { + workspaceId, + }), + }; + } + + return result; + } + + private buildRowToSelectQuery({ + currentEncryptionKeyId, + }: { + currentEncryptionKeyId: string; + }): SelectQueryBuilder { + const currentEnvelopePattern = + buildCurrentEncryptionKeyIdEnvelopeLikePattern(currentEncryptionKeyId); + + return this.connectedAccountRepository + .createQueryBuilder('connected_account') + .where('connected_account."connectionParameters" IS NOT NULL') + .andWhere( + `( + (connected_account."connectionParameters"->'IMAP'->>'password' IS NOT NULL + AND connected_account."connectionParameters"->'IMAP'->>'password' NOT LIKE :currentEnvelopePattern) + OR (connected_account."connectionParameters"->'SMTP'->>'password' IS NOT NULL + AND connected_account."connectionParameters"->'SMTP'->>'password' NOT LIKE :currentEnvelopePattern) + OR (connected_account."connectionParameters"->'CALDAV'->>'password' IS NOT NULL + AND connected_account."connectionParameters"->'CALDAV'->>'password' NOT LIKE :currentEnvelopePattern) + )`, + { currentEnvelopePattern }, + ); + } +} 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 index 1be743e0d69..553240b1c11 100644 --- 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 @@ -1,11 +1,16 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import { CommandLogger } from 'src/database/commands/logger'; +import { SECRET_ENCRYPTION_ROTATION_SITE_NAME } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant'; 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; +const KNOWN_SITE_NAMES = Object.values( + SECRET_ENCRYPTION_ROTATION_SITE_NAME, +).join(', '); + type RotateSecretEncryptionCommandOptions = { site?: string; batchSize?: number; @@ -32,8 +37,7 @@ export class RotateSecretEncryptionCommand extends CommandRunner { @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.', + description: `Limit rotation to a single site. Omit to run all sites. Known sites: ${KNOWN_SITE_NAMES}.`, required: false, }) parseSite(value: string): string { 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 index 02c502126df..7cbdae88ff1 100644 --- 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 @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConnectionParametersRotationHandler } from 'src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler'; 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'; @@ -27,6 +28,7 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac ]), ], providers: [ + ConnectionParametersRotationHandler, SensitiveConfigStorageRotationHandler, SecretEncryptionRotationRunnerService, RotateSecretEncryptionCommand, 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 index 0ae28726e61..ab0a39ef219 100644 --- 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 @@ -10,6 +10,7 @@ import { 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 { ConnectionParametersRotationHandler } from 'src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler'; import { SensitiveConfigStorageRotationHandler } from 'src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler'; import { SecretEncryptionRotationHandler, @@ -52,6 +53,7 @@ export class SecretEncryptionRotationRunnerService { constructor( private readonly environmentConfigDriver: EnvironmentConfigDriver, secretEncryptionService: SecretEncryptionService, + connectionParametersRotationHandler: ConnectionParametersRotationHandler, sensitiveConfigStorageRotationHandler: SensitiveConfigStorageRotationHandler, @InjectRepository(ApplicationRegistrationVariableEntity) applicationRegistrationVariableRepository: Repository, @@ -85,6 +87,7 @@ export class SecretEncryptionRotationRunnerService { }, secretEncryptionService, ), + connectionParametersRotationHandler, new ColumnRotationSiteHandler( { siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_VARIABLE, diff --git a/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts new file mode 100644 index 00000000000..6a3e0aa2632 --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts @@ -0,0 +1,177 @@ +import { ACCOUNT_TYPES, type AccountType } from 'twenty-shared/constants'; +import { type DataSource } from 'typeorm'; + +import { deleteConnectedAccount } from 'test/integration/metadata/suites/connected-account/utils/delete-connected-account.util'; +import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util'; +import { runSecretEncryptionRotationCommand } from 'test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util'; +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; + +import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; + +const V2_ENVELOPE_REGEX = /^enc:v2:[0-9a-f]{8}:[A-Za-z0-9+/=]+$/; + +const HANDLE = 'rotate-connection-parameters@example.com'; +const IMAP_PASSWORD = 'rotation-test-imap-password'; +const SMTP_PASSWORD = 'rotation-test-smtp-password'; +const CALDAV_PASSWORD = 'rotation-test-caldav-password'; + +type ConnectionParametersRow = { + workspaceId: string; + connectionParameters: ImapSmtpCaldavParams; +}; + +const readConnectionParameters = async ( + dataSource: DataSource, + connectedAccountId: string, +): Promise => { + const [row] = (await dataSource.query( + `SELECT "workspaceId", "connectionParameters" + FROM "core"."connectedAccount" + WHERE id = $1`, + [connectedAccountId], + )) as ConnectionParametersRow[]; + + expect(row).toBeDefined(); + expect(row.connectionParameters).toBeDefined(); + + return row; +}; + +const expectAllPasswordsDecryptTo = ({ + row, + secretEncryption, + expectedPlaintextByProtocol, +}: { + row: ConnectionParametersRow; + secretEncryption: SecretEncryptionService; + expectedPlaintextByProtocol: Record; +}): void => { + for (const protocol of ACCOUNT_TYPES) { + const params = row.connectionParameters[protocol]; + + expect(params).toBeDefined(); + expect(params?.password).toMatch(V2_ENVELOPE_REGEX); + + const decrypted = secretEncryption.decryptVersioned(params!.password, { + workspaceId: row.workspaceId, + }); + + expect(decrypted).toBe(expectedPlaintextByProtocol[protocol]); + } +}; + +describe('secret-encryption:rotate command — connection-parameters site (integration)', () => { + let dataSource: DataSource; + let secretEncryption: SecretEncryptionService; + let connectedAccountId: string; + + beforeAll(async () => { + dataSource = global.testDataSource; + secretEncryption = buildSecretEncryptionServiceFromEnv(); + + const { data } = await saveImapSmtpCaldavAccount({ + expectToFail: false, + input: { + handle: HANDLE, + connectionParameters: { + IMAP: { + host: 'imap.fastmail.com', + port: 993, + username: 'rotation@example.com', + password: IMAP_PASSWORD, + secure: true, + }, + SMTP: { + host: 'smtp.fastmail.com', + port: 465, + username: 'rotation@example.com', + password: SMTP_PASSWORD, + secure: true, + }, + CALDAV: { + host: 'caldav.fastmail.com', + port: 443, + username: 'rotation@example.com', + password: CALDAV_PASSWORD, + secure: true, + }, + }, + }, + }); + + connectedAccountId = data.connectedAccountId as string; + }, 120000); + + afterAll(async () => { + if (connectedAccountId !== undefined) { + await deleteConnectedAccount({ + id: connectedAccountId, + expectToFail: false, + }); + } + }); + + it('keeps every protocol password decryptable after running the rotation', async () => { + const beforeRotation = await readConnectionParameters( + dataSource, + connectedAccountId, + ); + + expectAllPasswordsDecryptTo({ + row: beforeRotation, + secretEncryption, + expectedPlaintextByProtocol: { + IMAP: IMAP_PASSWORD, + SMTP: SMTP_PASSWORD, + CALDAV: CALDAV_PASSWORD, + }, + }); + + await runSecretEncryptionRotationCommand(); + + const afterRotation = await readConnectionParameters( + dataSource, + connectedAccountId, + ); + + expectAllPasswordsDecryptTo({ + row: afterRotation, + secretEncryption, + expectedPlaintextByProtocol: { + IMAP: IMAP_PASSWORD, + SMTP: SMTP_PASSWORD, + CALDAV: CALDAV_PASSWORD, + }, + }); + + expect(afterRotation.connectionParameters.IMAP?.host).toBe( + 'imap.fastmail.com', + ); + expect(afterRotation.connectionParameters.SMTP?.port).toBe(465); + expect(afterRotation.connectionParameters.CALDAV?.username).toBe( + 'rotation@example.com', + ); + }, 90000); + + it('is idempotent when targeting only the connection-parameters site', async () => { + await runSecretEncryptionRotationCommand({ + site: 'connected-account-connection-parameters', + }); + await runSecretEncryptionRotationCommand({ + site: 'connected-account-connection-parameters', + }); + + const row = await readConnectionParameters(dataSource, connectedAccountId); + + expectAllPasswordsDecryptTo({ + row, + secretEncryption, + expectedPlaintextByProtocol: { + IMAP: IMAP_PASSWORD, + SMTP: SMTP_PASSWORD, + CALDAV: CALDAV_PASSWORD, + }, + }); + }, 120000); +});