Strictly type encryption rotation key site maps constants through entity type derivation (#21085)

# Introduction
Followup https://github.com/twentyhq/twenty/pull/21001

Now that the typeorm entities provide grains over their
`encryptedString` value, we can strictly type the sitemaps of the
encrypted string to rotate in case of encryption key rotation and also
the integration tests tests cases
This commit is contained in:
Paul Rastoin
2026-06-01 17:25:58 +02:00
committed by GitHub
parent d86e827563
commit 989b45db15
21 changed files with 422 additions and 187 deletions
@@ -0,0 +1,135 @@
import { type Type } from '@nestjs/common';
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 { type SecretEncryptionRotationHandler } from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { type ExtractEncryptedColumns } from 'src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type';
import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
type DedicatedRotationHandlerClass = Type<SecretEncryptionRotationHandler>;
type ColumnRotationSiteMetadata<E extends Type<unknown>> = {
siteName: string;
customHandler: DedicatedRotationHandlerClass | undefined;
isWorkspaceScoped: boolean;
extraWhere: Readonly<Partial<InstanceType<E>>> | undefined;
};
type SecretEncryptionRotationRegistryShape<R> = {
[N in keyof R]: R[N] extends { entity: infer E extends Type<unknown> }
? {
entity: E;
columnSiteNames: {
[K in ExtractEncryptedColumns<
InstanceType<E>
>]: ColumnRotationSiteMetadata<E>;
};
}
: never;
};
const defineRotationRegistry = <
const R extends SecretEncryptionRotationRegistryShape<R>,
>(
registry: R,
) => registry;
export const SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES = defineRotationRegistry({
ApplicationRegistrationVariableEntity: {
entity: ApplicationRegistrationVariableEntity,
columnSiteNames: {
encryptedValue: {
siteName: 'application-registration-variable',
customHandler: undefined,
isWorkspaceScoped: false,
extraWhere: undefined,
},
},
},
ApplicationVariableEntity: {
entity: ApplicationVariableEntity,
columnSiteNames: {
value: {
siteName: 'application-variable',
customHandler: undefined,
isWorkspaceScoped: true,
extraWhere: undefined,
},
},
},
ConnectedAccountEntity: {
entity: ConnectedAccountEntity,
columnSiteNames: {
accessToken: {
siteName: 'connected-account-access-token',
customHandler: undefined,
isWorkspaceScoped: true,
extraWhere: undefined,
},
refreshToken: {
siteName: 'connected-account-refresh-token',
customHandler: undefined,
isWorkspaceScoped: true,
extraWhere: undefined,
},
connectionParameters: {
siteName: 'connected-account-connection-parameters',
customHandler: ConnectionParametersRotationHandler,
isWorkspaceScoped: false,
extraWhere: undefined,
},
},
},
SigningKeyEntity: {
entity: SigningKeyEntity,
columnSiteNames: {
privateKey: {
siteName: 'signing-key-private-key',
customHandler: undefined,
isWorkspaceScoped: false,
extraWhere: undefined,
},
},
},
TwoFactorAuthenticationMethodEntity: {
entity: TwoFactorAuthenticationMethodEntity,
columnSiteNames: {
secret: {
siteName: 'totp-secret',
customHandler: undefined,
isWorkspaceScoped: true,
extraWhere: undefined,
},
},
},
});
// Sites whose encrypted-ness is per-row conditional and lives outside
// the type system (currently `KeyValuePairEntity.value` for sensitive
// CONFIG_VARIABLE rows of type STRING).
export const SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES = {
SENSITIVE_CONFIG_STORAGE: {
siteName: 'sensitive-config-storage',
handler: SensitiveConfigStorageRotationHandler,
},
} as const satisfies Record<
string,
{ siteName: string; handler: DedicatedRotationHandlerClass }
>;
type RegistryColumnMetadataUnion = {
[N in keyof typeof SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES]: (typeof SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES)[N]['columnSiteNames'][keyof (typeof SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES)[N]['columnSiteNames']];
}[keyof typeof SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES];
type TypedSiteNameUnion = RegistryColumnMetadataUnion['siteName'];
type UntypedSiteNameUnion =
(typeof SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES)[keyof typeof SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES]['siteName'];
export type SecretEncryptionRotationSiteName =
| TypedSiteNameUnion
| UntypedSiteNameUnion;
@@ -1,14 +0,0 @@
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',
SENSITIVE_CONFIG_STORAGE: 'sensitive-config-storage',
TOTP_SECRET: 'totp-secret',
} as const;
export type SecretEncryptionRotationSiteName =
(typeof SECRET_ENCRYPTION_ROTATION_SITE_NAME)[keyof typeof SECRET_ENCRYPTION_ROTATION_SITE_NAME];
@@ -7,7 +7,6 @@ import {
type SelectQueryBuilder,
} from 'typeorm';
import { type SecretEncryptionRotationSiteName } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant';
import {
SecretEncryptionRotationHandler,
type SecretEncryptionRotationContext,
@@ -23,9 +22,8 @@ const ZERO_UUID = '00000000-0000-0000-0000-000000000000';
type EntityWithId = ObjectLiteral & { id: string };
export type ColumnRotationSiteConfig<Entity extends EntityWithId> = {
siteName: SecretEncryptionRotationSiteName;
repository: Repository<Entity>;
encryptedColumn: keyof Entity & string;
encryptedColumn: string;
isWorkspaceScoped?: boolean;
extraWhere?: Partial<Entity>;
};
@@ -33,7 +31,6 @@ export type ColumnRotationSiteConfig<Entity extends EntityWithId> = {
export class ColumnRotationSiteHandler<
Entity extends EntityWithId = EntityWithId,
> extends SecretEncryptionRotationHandler {
readonly siteName: SecretEncryptionRotationSiteName;
private readonly logger = new Logger(ColumnRotationSiteHandler.name);
constructor(
@@ -41,14 +38,14 @@ export class ColumnRotationSiteHandler<
private readonly secretEncryptionService: SecretEncryptionService,
) {
super();
this.siteName = config.siteName;
}
async countRemaining({
currentEncryptionKeyId,
}: {
currentEncryptionKeyId: string;
}): Promise<number> {
}: Pick<
SecretEncryptionRotationContext,
'siteName' | 'currentEncryptionKeyId'
>): Promise<number> {
const currentEnvelopePattern =
buildCurrentEncryptionKeyIdEnvelopeLikePattern(currentEncryptionKeyId);
@@ -62,6 +59,7 @@ export class ColumnRotationSiteHandler<
}
async rotate({
siteName,
currentEncryptionKeyId,
batchSize,
dryRun,
@@ -92,7 +90,7 @@ export class ColumnRotationSiteHandler<
}
for (const row of rows) {
const rowOutcome = await this.rotateRow({ row, dryRun });
const rowOutcome = await this.rotateRow({ siteName, row, dryRun });
outcome.rotated += rowOutcome.rotated;
outcome.skipped += rowOutcome.skipped;
@@ -106,9 +104,11 @@ export class ColumnRotationSiteHandler<
}
private async rotateRow({
siteName,
row,
dryRun,
}: {
siteName: SecretEncryptionRotationContext['siteName'];
row: Entity;
dryRun: boolean;
}): Promise<SecretEncryptionRotationOutcome> {
@@ -116,9 +116,13 @@ export class ColumnRotationSiteHandler<
const rowId = row.id;
const currentValue = row[encryptedColumn] as string | null | undefined;
if (currentValue === '') {
return { rotated: 0, skipped: 1, errors: 0 };
}
if (!isDefined(currentValue) || !isEncryptedString(currentValue)) {
this.logger.error(
`[${this.siteName}] row ${rowId}: column '${encryptedColumn}' is not a versioned envelope, refusing to rotate.`,
`[${siteName}] row ${rowId}: column '${encryptedColumn}' is not a versioned envelope, refusing to rotate.`,
);
return { rotated: 0, skipped: 0, errors: 1 };
@@ -155,7 +159,7 @@ export class ColumnRotationSiteHandler<
return { rotated: 1, skipped: 0, errors: 0 };
} catch (error) {
this.logger.error(buildRotationErrorMessage(this.siteName, rowId, error));
this.logger.error(buildRotationErrorMessage(siteName, rowId, error));
return { rotated: 0, skipped: 0, errors: 1 };
}
@@ -5,7 +5,6 @@ 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,
@@ -29,8 +28,6 @@ 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,
);
@@ -45,13 +42,15 @@ export class ConnectionParametersRotationHandler extends SecretEncryptionRotatio
async countRemaining({
currentEncryptionKeyId,
}: {
currentEncryptionKeyId: string;
}): Promise<number> {
}: Pick<
SecretEncryptionRotationContext,
'siteName' | 'currentEncryptionKeyId'
>): Promise<number> {
return this.buildRowToSelectQuery({ currentEncryptionKeyId }).getCount();
}
async rotate({
siteName,
currentEncryptionKeyId,
batchSize,
dryRun,
@@ -91,9 +90,7 @@ export class ConnectionParametersRotationHandler extends SecretEncryptionRotatio
workspaceId: row.workspaceId,
});
} catch (error) {
this.logger.error(
buildRotationErrorMessage(this.siteName, row.id, error),
);
this.logger.error(buildRotationErrorMessage(siteName, row.id, error));
outcome.errors += 1;
continue;
}
@@ -5,7 +5,6 @@ import { isNonEmptyString } from '@sniptt/guards';
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,
@@ -26,8 +25,6 @@ import { TypedReflect } from 'src/utils/typed-reflect';
@Injectable()
export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotationHandler {
readonly siteName =
SECRET_ENCRYPTION_ROTATION_SITE_NAME.SENSITIVE_CONFIG_STORAGE;
private readonly logger = new Logger(
SensitiveConfigStorageRotationHandler.name,
);
@@ -42,9 +39,10 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat
async countRemaining({
currentEncryptionKeyId,
}: {
currentEncryptionKeyId: string;
}): Promise<number> {
}: Pick<
SecretEncryptionRotationContext,
'siteName' | 'currentEncryptionKeyId'
>): Promise<number> {
const sensitiveStringConfigKeys = this.collectSensitiveStringConfigKeys();
if (sensitiveStringConfigKeys.length === 0) {
@@ -58,6 +56,7 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat
}
async rotate({
siteName,
currentEncryptionKeyId,
batchSize,
dryRun,
@@ -90,7 +89,7 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat
}
for (const row of rows) {
const rowOutcome = await this.rotateRow({ row, dryRun });
const rowOutcome = await this.rotateRow({ siteName, row, dryRun });
outcome.rotated += rowOutcome.rotated;
outcome.skipped += rowOutcome.skipped;
@@ -104,9 +103,11 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat
}
private async rotateRow({
siteName,
row,
dryRun,
}: {
siteName: SecretEncryptionRotationContext['siteName'];
row: KeyValuePairEntity;
dryRun: boolean;
}): Promise<SecretEncryptionRotationOutcome> {
@@ -139,9 +140,7 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat
return { rotated: 1, skipped: 0, errors: 0 };
} catch (error) {
this.logger.error(
buildRotationErrorMessage(this.siteName, row.id, error),
);
this.logger.error(buildRotationErrorMessage(siteName, row.id, error));
return { rotated: 0, skipped: 0, errors: 1 };
}
@@ -1,6 +1,7 @@
import { type SecretEncryptionRotationSiteName } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant';
import { type SecretEncryptionRotationSiteName } from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant';
export type SecretEncryptionRotationContext = {
siteName: SecretEncryptionRotationSiteName;
currentEncryptionKeyId: string;
batchSize: number;
dryRun: boolean;
@@ -20,11 +21,12 @@ export type SecretEncryptionRotationSiteResult =
};
export abstract class SecretEncryptionRotationHandler {
abstract readonly siteName: SecretEncryptionRotationSiteName;
abstract countRemaining(args: {
currentEncryptionKeyId: string;
}): Promise<number>;
abstract countRemaining(
args: Pick<
SecretEncryptionRotationContext,
'siteName' | 'currentEncryptionKeyId'
>,
): Promise<number>;
abstract rotate(
context: SecretEncryptionRotationContext,
@@ -1,16 +1,11 @@
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;
@@ -20,7 +15,7 @@ type RotateSecretEncryptionCommandOptions = {
@Command({
name: 'secret-encryption:rotate',
description:
'Re-encrypts every at-rest secret stored in an enc:v2 envelope using the current ENCRYPTION_KEY. Idempotent: rows already on the current key are skipped via a SQL filter, so the command is safe to interrupt and re-run. Requires FALLBACK_ENCRYPTION_KEY to be set to the previous key when rotating to a fresh ENCRYPTION_KEY.',
'Re-encrypts every at-rest secret stored in an enc:v2 envelope using the current ENCRYPTION_KEY. Idempotent: rows already on the current key are skipped via a SQL filter, so the command is safe to interrupt and re-run. Requires FALLBACK_ENCRYPTION_KEY to be set to the previous key when rotating to a fresh ENCRYPTION_KEY. Pass --site=<name> to scope to a single site; an invalid value lists all available sites.',
})
export class RotateSecretEncryptionCommand extends CommandRunner {
protected logger: CommandLogger;
@@ -37,7 +32,8 @@ export class RotateSecretEncryptionCommand extends CommandRunner {
@Option({
flags: '-s, --site <site>',
description: `Limit rotation to a single site. Omit to run all sites. Known sites: ${KNOWN_SITE_NAMES}.`,
description:
'Limit rotation to a single site. Omit to run all sites. An invalid value lists all available sites.',
required: false,
})
parseSite(value: string): string {
@@ -1,35 +1,43 @@
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 { isDefined } from 'twenty-shared/utils';
import {
SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES,
SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES,
} from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant';
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';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
const ROTATION_ENTITIES = [
...Object.values(SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES).map(
(entry) => entry.entity,
),
KeyValuePairEntity,
];
const DEDICATED_ROTATION_HANDLERS = [
...Object.values(SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES)
.flatMap((entry) => Object.values(entry.columnSiteNames))
.map((meta) => meta.customHandler)
.filter(isDefined),
...Object.values(SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES).map(
(entry) => entry.handler,
),
];
@Module({
imports: [
SecretEncryptionModule,
TwentyConfigModule,
TypeOrmModule.forFeature([
ApplicationRegistrationVariableEntity,
ApplicationVariableEntity,
ConnectedAccountEntity,
KeyValuePairEntity,
SigningKeyEntity,
TwoFactorAuthenticationMethodEntity,
]),
TypeOrmModule.forFeature(ROTATION_ENTITIES),
],
providers: [
ConnectionParametersRotationHandler,
SensitiveConfigStorageRotationHandler,
...DEDICATED_ROTATION_HANDLERS,
SecretEncryptionRotationRunnerService,
RotateSecretEncryptionCommand,
],
@@ -1,30 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { performance } from 'perf_hooks';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { DataSource } from 'typeorm';
import {
SECRET_ENCRYPTION_ROTATION_SITE_NAME,
SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES,
SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES,
type SecretEncryptionRotationSiteName,
} from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant';
} from 'src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.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,
type SecretEncryptionRotationSiteResult,
} from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { computeEncryptionKeyId } from 'src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util';
import { resolveEncryptionKeysOrThrow } from 'src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util';
import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { isDefined, typedObjectEntries } from 'twenty-shared/utils';
export type RotationRunOptions = {
site?: SecretEncryptionRotationSiteName | string;
@@ -40,99 +35,57 @@ export type RotationRunSummary = {
};
@Injectable()
export class SecretEncryptionRotationRunnerService {
export class SecretEncryptionRotationRunnerService implements OnModuleInit {
private readonly logger = new Logger(
SecretEncryptionRotationRunnerService.name,
);
private readonly handlersBySiteName: Map<
private readonly handlersBySiteName = new Map<
SecretEncryptionRotationSiteName,
SecretEncryptionRotationHandler
>;
>();
constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver,
secretEncryptionService: SecretEncryptionService,
connectionParametersRotationHandler: ConnectionParametersRotationHandler,
sensitiveConfigStorageRotationHandler: SensitiveConfigStorageRotationHandler,
@InjectRepository(ApplicationRegistrationVariableEntity)
applicationRegistrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
@InjectRepository(ApplicationVariableEntity)
applicationVariableRepository: Repository<ApplicationVariableEntity>,
@InjectRepository(ConnectedAccountEntity)
connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(SigningKeyEntity)
signingKeyRepository: Repository<SigningKeyEntity>,
// Secret-encryption key rotation sweeps every row across every workspace.
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
@InjectRepository(TwoFactorAuthenticationMethodEntity)
twoFactorAuthenticationMethodRepository: Repository<TwoFactorAuthenticationMethodEntity>,
) {
const handlers: SecretEncryptionRotationHandler[] = [
new ColumnRotationSiteHandler(
{
siteName:
SECRET_ENCRYPTION_ROTATION_SITE_NAME.CONNECTED_ACCOUNT_ACCESS_TOKEN,
repository: connectedAccountRepository,
encryptedColumn: 'accessToken',
isWorkspaceScoped: true,
},
secretEncryptionService,
),
new ColumnRotationSiteHandler(
{
siteName:
SECRET_ENCRYPTION_ROTATION_SITE_NAME.CONNECTED_ACCOUNT_REFRESH_TOKEN,
repository: connectedAccountRepository,
encryptedColumn: 'refreshToken',
isWorkspaceScoped: true,
},
secretEncryptionService,
),
connectionParametersRotationHandler,
new ColumnRotationSiteHandler(
{
siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_VARIABLE,
repository: applicationVariableRepository,
encryptedColumn: 'value',
isWorkspaceScoped: true,
extraWhere: { isSecret: true },
},
secretEncryptionService,
),
new ColumnRotationSiteHandler(
{
siteName:
SECRET_ENCRYPTION_ROTATION_SITE_NAME.APPLICATION_REGISTRATION_VARIABLE,
repository: applicationRegistrationVariableRepository,
encryptedColumn: 'encryptedValue',
},
secretEncryptionService,
),
new ColumnRotationSiteHandler(
{
siteName:
SECRET_ENCRYPTION_ROTATION_SITE_NAME.SIGNING_KEY_PRIVATE_KEY,
repository: signingKeyRepository,
encryptedColumn: 'privateKey',
},
secretEncryptionService,
),
new ColumnRotationSiteHandler(
{
siteName: SECRET_ENCRYPTION_ROTATION_SITE_NAME.TOTP_SECRET,
repository: twoFactorAuthenticationMethodRepository,
encryptedColumn: 'secret',
isWorkspaceScoped: true,
},
secretEncryptionService,
),
sensitiveConfigStorageRotationHandler,
];
private readonly secretEncryptionService: SecretEncryptionService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly moduleRef: ModuleRef,
) {}
this.handlersBySiteName = new Map(
handlers.map((handler) => [handler.siteName, handler]),
);
onModuleInit(): void {
for (const entry of Object.values(
SECRET_ENCRYPTION_ROTATION_SITE_ENTRIES,
)) {
const repository = this.coreDataSource.getRepository(entry.entity);
for (const [encryptedColumn, meta] of typedObjectEntries(
entry.columnSiteNames,
)) {
const handler = isDefined(meta.customHandler)
? this.moduleRef.get(meta.customHandler)
: new ColumnRotationSiteHandler(
{
repository,
encryptedColumn,
isWorkspaceScoped: meta.isWorkspaceScoped,
extraWhere: meta.extraWhere,
},
this.secretEncryptionService,
);
this.handlersBySiteName.set(meta.siteName, handler);
}
}
for (const entry of Object.values(
SECRET_ENCRYPTION_ROTATION_UNTYPED_SITE_ENTRIES,
)) {
this.handlersBySiteName.set(
entry.siteName,
this.moduleRef.get(entry.handler),
);
}
}
listSiteNames(): SecretEncryptionRotationSiteName[] {
@@ -172,18 +125,20 @@ export class SecretEncryptionRotationRunnerService {
const startedAt = performance.now();
const results: SecretEncryptionRotationSiteResult[] = [];
for (const handler of handlersToRun) {
for (const [siteName, handler] of handlersToRun) {
const siteStartedAt = performance.now();
const remainingBefore = await handler.countRemaining({
siteName,
currentEncryptionKeyId,
});
this.logger.log(
`[${handler.siteName}] start: ${remainingBefore} row(s) need rotation`,
`[${siteName}] start: ${remainingBefore} row(s) need rotation`,
);
const { rotated, skipped, errors } = await handler.rotate({
siteName,
currentEncryptionKeyId,
batchSize: options.batchSize,
dryRun: options.dryRun,
@@ -191,7 +146,7 @@ export class SecretEncryptionRotationRunnerService {
const durationMs = Math.round(performance.now() - siteStartedAt);
const result: SecretEncryptionRotationSiteResult = {
siteName: handler.siteName,
siteName,
remainingBefore,
rotated,
skipped,
@@ -202,7 +157,7 @@ export class SecretEncryptionRotationRunnerService {
results.push(result);
this.logger.log(
`[${handler.siteName}] DONE in ${durationMs}ms — rotated=${rotated} skipped=${skipped} errors=${errors}`,
`[${siteName}] DONE in ${durationMs}ms — rotated=${rotated} skipped=${skipped} errors=${errors}`,
);
}
@@ -225,14 +180,15 @@ export class SecretEncryptionRotationRunnerService {
private resolveHandlersToRun(
site: string | undefined,
): SecretEncryptionRotationHandler[] {
): Array<
[SecretEncryptionRotationSiteName, SecretEncryptionRotationHandler]
> {
if (!isDefined(site)) {
return Array.from(this.handlersBySiteName.values());
return Array.from(this.handlersBySiteName.entries());
}
const handler = this.handlersBySiteName.get(
site as SecretEncryptionRotationSiteName,
);
const siteName = site as SecretEncryptionRotationSiteName;
const handler = this.handlersBySiteName.get(siteName);
if (!isDefined(handler)) {
throw new Error(
@@ -242,7 +198,7 @@ export class SecretEncryptionRotationRunnerService {
);
}
return [handler];
return [[siteName, handler]];
}
private logSummary(summary: RotationRunSummary): void {
@@ -0,0 +1,108 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type EmptyObject } from 'twenty-shared/types';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type ExtractEncryptedColumns } from 'src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
type EncryptedConnectionParametersLike = {
IMAP?: { host: string; password: EncryptedString };
SMTP?: { host: string; password: EncryptedString };
};
type DeeplyNestedThreeLevels = {
level1: {
level2: {
level3: EncryptedString;
};
};
};
class FakeRelatedEntity extends WorkspaceRelatedEntity {
fakeId: string;
encryptedTokenOnRelatedEntity: EncryptedString | null;
}
type TestedRecord = {
// Non-EncryptedString fields - must NOT be extracted
plainString: string;
plainStringNullable: string | null;
plainNumber: number;
plainBoolean: boolean;
plainDate: Date;
plainObject: EmptyObject;
plainArray: string[];
plainUnknown: unknown;
plaintextBranded: PlaintextString;
plaintextBrandedNullable: PlaintextString | null;
emptyStringLiteral: '';
encVersionedLiteral: 'enc:v2:xxx';
recordWithoutEncryption: { host: string; port: number };
arrayOfPlaintextRecords: Array<{ host: string }>;
// Direct EncryptedString fields - MUST be extracted
encryptedRequired: EncryptedString;
encryptedNullable: EncryptedString | null;
encryptedUndefinable: EncryptedString | undefined;
encryptedOptional?: EncryptedString;
encryptedOrEmpty: EncryptedString | '';
encryptedUnionWithPrimitive: EncryptedString | string;
encryptedUnionWithPlaintext: EncryptedString | PlaintextString;
// Nested EncryptedString — MUST be extracted (transitive structural)
connectionParametersLike: EncryptedConnectionParametersLike;
connectionParametersLikeNullable: EncryptedConnectionParametersLike | null;
arrayOfRecordsWithEncrypted: Array<{ secret: EncryptedString }>;
recordWithEncryptedAtTopLevel: { secret: EncryptedString };
deeplyNested: DeeplyNestedThreeLevels;
// Entity relations — MUST NOT be extracted, even though the related
// entity carries an EncryptedString column. Stripped by
// `ExtractEntityRelatedEntityProperties` before recursion.
relatedEntity: FakeRelatedEntity;
relatedEntityNullable: FakeRelatedEntity | null;
relatedEntityArray: FakeRelatedEntity[];
};
type TestResult = ExtractEncryptedColumns<TestedRecord>;
// oxlint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<
Equal<
TestResult,
| 'encryptedRequired'
| 'encryptedNullable'
| 'encryptedUndefinable'
| 'encryptedOptional'
| 'encryptedOrEmpty'
| 'encryptedUnionWithPrimitive'
| 'encryptedUnionWithPlaintext'
| 'connectionParametersLike'
| 'connectionParametersLikeNullable'
| 'arrayOfRecordsWithEncrypted'
| 'recordWithEncryptedAtTopLevel'
| 'deeplyNested'
>
>,
// Empty object returns never
Expect<Equal<ExtractEncryptedColumns<EmptyObject>, never>>,
// Object with no EncryptedString fields returns never
Expect<
Equal<ExtractEncryptedColumns<{ a: string; b: number; c: '' }>, never>
>,
// Object with only PlaintextString fields returns never (brands don't cross)
Expect<
Equal<
ExtractEncryptedColumns<{
a: PlaintextString;
b: PlaintextString | null;
}>,
never
>
>,
];
@@ -0,0 +1,13 @@
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
export type ContainsEncryptedString<T> = T extends EncryptedString
? true
: T extends ReadonlyArray<infer U>
? ContainsEncryptedString<U>
: T extends object
? true extends {
[K in keyof T]: ContainsEncryptedString<NonNullable<T[K]>>;
}[keyof T]
? true
: false
: false;
@@ -0,0 +1,14 @@
import { type ContainsEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type';
import { type ExtractEntityRelatedEntityProperties } from 'src/engine/metadata-modules/flat-entity/types/extract-entity-related-entity-properties.type';
type ExtractEncryptedColumnsFromShape<T> = NonNullable<
{
[P in keyof T]-?: true extends ContainsEncryptedString<NonNullable<T[P]>>
? P
: never;
}[keyof T]
>;
export type ExtractEncryptedColumns<T> = ExtractEncryptedColumnsFromShape<
Omit<T, ExtractEntityRelatedEntityProperties<T>>
>;
@@ -1,3 +1,5 @@
export { type ContainsEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type';
export { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
export { type ExtractEncryptedColumns } from 'src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type';
export { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util';
export { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
@@ -104,8 +104,8 @@ export class WorkspaceScopedRepository<T extends WorkspaceScopedEntity> {
): Promise<number | null> {
this.assertWorkspaceId(workspaceId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.repository.maximum(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columnName as any,
where
? this.mergeWorkspaceIdIntoCriteria(workspaceId, where)
@@ -1,8 +1,12 @@
import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { type ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { type BillingMeterEntity } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { type BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { type SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { type KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { type TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -31,11 +35,15 @@ export type AllNonWorkspaceRelatedEntity =
| AgentTurnEntity
| AgentTurnEvaluationEntity
| IndexFieldMetadataEntity
| ApplicationRegistrationEntity
| ApplicationRegistrationVariableEntity
| ApplicationVariableEntity
| BillingMeterEntity
| BillingPriceEntity
| BillingProductEntity
| BillingSubscriptionItemEntity
| KeyValuePairEntity
| SigningKeyEntity
| TwoFactorAuthenticationMethodEntity
| UserEntity
| WorkspaceEntity;
@@ -1,11 +1,9 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type EmptyObject } from 'twenty-shared/types';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// oxlint-disable-next-line @typescripttypescript/no-empty-object-type
type EmptyObject = {};
type TestedRecord = {
// Non-JsonbProperty fields
plainString: string;
@@ -1,13 +1,11 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type EmptyObject } from 'twenty-shared/types';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// oxlint-disable-next-line @typescripttypescript/no-empty-object-type
type EmptyObject = {};
type SimpleObject = { value: string };
type NestedObject = { nested: { deep: number } };
@@ -0,0 +1,2 @@
// oxlint-disable-next-line typescript/no-empty-object-type
export type EmptyObject = {};
@@ -63,6 +63,7 @@ export { ContextStorePageType } from './ContextStorePageType';
export { CoreObjectNameSingular } from './CoreObjectNameSingular';
export { CrudOperationType } from './CrudOperationType';
export type { EmailAttachment } from './EmailAttachment';
export type { EmptyObject } from './EmptyObject.type';
export type {
SnackBarVariant,
EnqueueSnackbarParams,
@@ -194,6 +194,7 @@ export {
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';
export { typedObjectEntries } from './typed-object-entries';
export { isMetadataGqlOperationSignature } from './typeguard/isMetadataGqlOperationSignature';
export { isPlainObject } from './typeguard/isPlainObject';
export { isRecordGqlOperationSignature } from './typeguard/isRecordGqlOperationSignature';
@@ -0,0 +1,7 @@
type StrictEntries<T> = T extends unknown
? { [K in keyof T]-?: [K, T[K]] }[keyof T]
: never;
export const typedObjectEntries = <T extends Record<string, unknown>>(
object: T,
): Array<StrictEntries<T>> => Object.entries(object) as Array<StrictEntries<T>>;