Files
twenty/packages/twenty-server/test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util.ts
T
Charles Bochet 9988f98577 feat(server): idempotent CLI to rotate ENCRYPTION_KEY across enc:v2 rows (#20613)
## Summary
Adds the \`secret-encryption:rotate\` CLI command, which re-encrypts
every at-rest secret stored in an \`enc:v2:\` envelope under the current
\`ENCRYPTION_KEY\`. The command is **online** and **resumable**: a SQL
filter skips rows already on the current keyId, so interrupting it
(Ctrl-C, container restart, …) and re-running picks up where it left off
without re-rotating earlier rows.

### Sites covered (one handler each)
| Site | Table.column | Scope |
| --- | --- | --- |
| \`connected-account-tokens\` | \`connectedAccount.{accessToken,
refreshToken}\` | workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret
only) | workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-keys\` | \`signingKey.privateKey\` | instance |
| \`sensitive-config-storage\` | \`keyValuePair.value\` (isSensitive +
STRING configs) | instance |
| \`totp-secrets\` | \`twoFactorAuthenticationMethod.secret\` |
workspace |

Each handler:
- Filters at SQL level on \`value LIKE 'enc:v2:%' AND value NOT LIKE
'enc:v2:<primaryKeyId>:%'\` to enforce idempotency without re-decrypting
already-rotated rows.
- Uses cursor-based batching (default **200**, capped **5000**).
- Threads \`workspaceId\` into HKDF for workspace-scoped sites; runs
instance-scoped for the rest.

### CLI flags
| Flag | Description |
| --- | --- |
| \`-s, --site <site>\` | Limit to a single site. |
| \`-b, --batch-size <n>\` | Override per-batch row count. |
| \`-d, --dry-run\` | Decrypt + re-encrypt in memory, skip the
\`UPDATE\`. |

The runner logs progress via Nest \`Logger\` (per-site start,
completion, final summary) and exits non-zero when any site reports
\`errors > 0\`. \`FALLBACK_ENCRYPTION_KEY\` must be set to the previous
\`ENCRYPTION_KEY\` during rotation; the runner warns when it is unset.

Operator documentation lives in #20611 (docs PR).
2026-05-20 17:51:29 +00:00

75 lines
1.7 KiB
TypeScript

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<void> => {
await new Promise<void>((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}`,
),
);
});
});
};