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).
67 lines
1.6 KiB
TypeScript
67 lines
1.6 KiB
TypeScript
import gql from 'graphql-tag';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
|
|
export type ApplicationVariableSummary = {
|
|
key: string;
|
|
value: string;
|
|
isSecret: boolean;
|
|
};
|
|
|
|
export const findOneApplicationIdByUniversalIdentifier = async ({
|
|
universalIdentifier,
|
|
}: {
|
|
universalIdentifier: string;
|
|
}): Promise<string> => {
|
|
const response = await makeMetadataAPIRequest({
|
|
query: gql`
|
|
query FindOneApplicationIdByUniversalIdentifier(
|
|
$universalIdentifier: UUID!
|
|
) {
|
|
findOneApplication(universalIdentifier: $universalIdentifier) {
|
|
id
|
|
}
|
|
}
|
|
`,
|
|
variables: { universalIdentifier },
|
|
});
|
|
|
|
const id: string | undefined = response.body?.data?.findOneApplication?.id;
|
|
|
|
if (!isDefined(id)) {
|
|
throw new Error(
|
|
`findOneApplication did not return an id for universalIdentifier=${universalIdentifier}: ${JSON.stringify(
|
|
response.body,
|
|
)}`,
|
|
);
|
|
}
|
|
|
|
return id;
|
|
};
|
|
|
|
export const findOneApplicationVariables = async ({
|
|
id,
|
|
}: {
|
|
id: string;
|
|
}): Promise<ApplicationVariableSummary[]> => {
|
|
const response = await makeMetadataAPIRequest({
|
|
query: gql`
|
|
query FindOneApplicationVariables($id: UUID!) {
|
|
findOneApplication(id: $id) {
|
|
applicationVariables {
|
|
key
|
|
value
|
|
isSecret
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { id },
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
return response.body.data.findOneApplication.applicationVariables;
|
|
};
|