mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-12 09:57:03 -04:00
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:
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user