Files
twenty/packages/twenty-server/test/integration/secret-encryption/utils/find-one-application.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

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;
};