9988f98577
## 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).
75 lines
1.7 KiB
TypeScript
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}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
};
|