feat(server): rotate connectedAccount.connectionParameters via secret-encryption:rotate (#20807)

## 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->'<PROTOCOL>'->>'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 |
This commit is contained in:
Charles Bochet
2026-05-21 19:31:52 +02:00
committed by GitHub
parent 64241ed8ae
commit edf2bfbf76
6 changed files with 386 additions and 2 deletions

View File

@@ -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',

View File

@@ -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<ConnectedAccountEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {
super();
}
async countRemaining({
currentEncryptionKeyId,
}: {
currentEncryptionKeyId: string;
}): Promise<number> {
return this.buildRowToSelectQuery({ currentEncryptionKeyId }).getCount();
}
async rotate({
currentEncryptionKeyId,
batchSize,
dryRun,
}: SecretEncryptionRotationContext): Promise<SecretEncryptionRotationOutcome> {
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<ConnectedAccountEntity> {
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 },
);
}
}

View File

@@ -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 <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 {

View File

@@ -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,

View File

@@ -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<ApplicationRegistrationVariableEntity>,
@@ -85,6 +87,7 @@ export class SecretEncryptionRotationRunnerService {
},
secretEncryptionService,
),
connectionParametersRotationHandler,
new ColumnRotationSiteHandler(
{
siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_VARIABLE,

View File

@@ -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<ConnectionParametersRow> => {
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<AccountType, string>;
}): 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);
});