From 989b45db156984a5b031439f637cb5469914e2d5 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:25:58 +0200 Subject: [PATCH] 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 --- ...cryption-rotation-site-entries.constant.ts | 135 ++++++++++++++ ...-encryption-rotation-site-name.constant.ts | 14 -- .../handlers/column-rotation-site.handler.ts | 26 +-- .../connection-parameters-rotation.handler.ts | 15 +- ...nsitive-config-storage-rotation.handler.ts | 19 +- ...t-encryption-rotation-handler.interface.ts | 14 +- .../rotate-secret-encryption.command.ts | 10 +- .../secret-encryption-rotation.module.ts | 42 +++-- ...cret-encryption-rotation-runner.service.ts | 168 +++++++----------- .../extract-encrypted-columns.type-test.ts | 108 +++++++++++ .../contains-encrypted-string.type.ts | 13 ++ .../extract-encrypted-columns.type.ts | 14 ++ .../branded-strings/index.ts | 2 + .../workspace-scoped-repository.ts | 2 +- .../all-non-workspace-related-entity.type.ts | 8 + .../extract-jsonb-properties.type-test.ts | 4 +- .../__tests__/jsonb-property.type-test.ts | 4 +- .../src/types/EmptyObject.type.ts | 2 + packages/twenty-shared/src/types/index.ts | 1 + packages/twenty-shared/src/utils/index.ts | 1 + .../src/utils/typed-object-entries.ts | 7 + 21 files changed, 422 insertions(+), 187 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant.ts delete mode 100644 packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/extract-encrypted-columns.type-test.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type.ts create mode 100644 packages/twenty-shared/src/types/EmptyObject.type.ts create mode 100644 packages/twenty-shared/src/utils/typed-object-entries.ts diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant.ts new file mode 100644 index 0000000000..7dc404c310 --- /dev/null +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-entries.constant.ts @@ -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; + +type ColumnRotationSiteMetadata> = { + siteName: string; + customHandler: DedicatedRotationHandlerClass | undefined; + isWorkspaceScoped: boolean; + extraWhere: Readonly>> | undefined; +}; + +type SecretEncryptionRotationRegistryShape = { + [N in keyof R]: R[N] extends { entity: infer E extends Type } + ? { + entity: E; + columnSiteNames: { + [K in ExtractEncryptedColumns< + InstanceType + >]: ColumnRotationSiteMetadata; + }; + } + : never; +}; + +const defineRotationRegistry = < + const R extends SecretEncryptionRotationRegistryShape, +>( + 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; diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts deleted file mode 100644 index 4f4671d92f..0000000000 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/constants/secret-encryption-rotation-site-name.constant.ts +++ /dev/null @@ -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]; diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts index 065e4dc7c9..6cfdcc5ea1 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/column-rotation-site.handler.ts @@ -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 = { - siteName: SecretEncryptionRotationSiteName; repository: Repository; - encryptedColumn: keyof Entity & string; + encryptedColumn: string; isWorkspaceScoped?: boolean; extraWhere?: Partial; }; @@ -33,7 +31,6 @@ export type ColumnRotationSiteConfig = { 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 { + }: Pick< + SecretEncryptionRotationContext, + 'siteName' | 'currentEncryptionKeyId' + >): Promise { 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 { @@ -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 }; } diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts index 4563c4262a..26607d010f 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/connection-parameters-rotation.handler.ts @@ -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 { + }: Pick< + SecretEncryptionRotationContext, + 'siteName' | 'currentEncryptionKeyId' + >): Promise { 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; } diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts index 193ec081b7..2feb50ceab 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/handlers/sensitive-config-storage-rotation.handler.ts @@ -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 { + }: Pick< + SecretEncryptionRotationContext, + 'siteName' | 'currentEncryptionKeyId' + >): Promise { 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 { @@ -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 }; } diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts index 62e5b0a6e2..cc91dc0fdb 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface.ts @@ -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; + abstract countRemaining( + args: Pick< + SecretEncryptionRotationContext, + 'siteName' | 'currentEncryptionKeyId' + >, + ): Promise; abstract rotate( context: SecretEncryptionRotationContext, diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts index 553240b1c1..e591d07a8e 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/rotate-secret-encryption.command.ts @@ -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= 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 ', - 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 { diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts index 7cbdae88ff..aa3c3baf10 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module.ts @@ -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, ], diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts index 9244d65da6..700dfdde36 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts @@ -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, - @InjectRepository(ApplicationVariableEntity) - applicationVariableRepository: Repository, - @InjectRepository(ConnectedAccountEntity) - connectedAccountRepository: Repository, - @InjectRepository(SigningKeyEntity) - signingKeyRepository: Repository, - // Secret-encryption key rotation sweeps every row across every workspace. - // eslint-disable-next-line twenty/prefer-workspace-scoped-repository - @InjectRepository(TwoFactorAuthenticationMethodEntity) - twoFactorAuthenticationMethodRepository: Repository, - ) { - 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 { diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/extract-encrypted-columns.type-test.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/extract-encrypted-columns.type-test.ts new file mode 100644 index 0000000000..8f984ceb02 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/extract-encrypted-columns.type-test.ts @@ -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; + +// 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, never>>, + + // Object with no EncryptedString fields returns never + Expect< + Equal, never> + >, + + // Object with only PlaintextString fields returns never (brands don't cross) + Expect< + Equal< + ExtractEncryptedColumns<{ + a: PlaintextString; + b: PlaintextString | null; + }>, + never + > + >, +]; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type.ts new file mode 100644 index 0000000000..96ac28c41f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type.ts @@ -0,0 +1,13 @@ +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; + +export type ContainsEncryptedString = T extends EncryptedString + ? true + : T extends ReadonlyArray + ? ContainsEncryptedString + : T extends object + ? true extends { + [K in keyof T]: ContainsEncryptedString>; + }[keyof T] + ? true + : false + : false; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type.ts new file mode 100644 index 0000000000..1fa70b3139 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type.ts @@ -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 = NonNullable< + { + [P in keyof T]-?: true extends ContainsEncryptedString> + ? P + : never; + }[keyof T] +>; + +export type ExtractEncryptedColumns = ExtractEncryptedColumnsFromShape< + Omit> +>; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/index.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/index.ts index eb2d5b4b64..c00c2b1e77 100644 --- a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/index.ts +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/index.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts index 92b162e7a6..715db9c507 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts @@ -104,8 +104,8 @@ export class WorkspaceScopedRepository { ): Promise { 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) diff --git a/packages/twenty-server/src/engine/workspace-manager/types/all-non-workspace-related-entity.type.ts b/packages/twenty-server/src/engine/workspace-manager/types/all-non-workspace-related-entity.type.ts index e251242ad9..1e31091d9e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/types/all-non-workspace-related-entity.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/types/all-non-workspace-related-entity.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/extract-jsonb-properties.type-test.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/extract-jsonb-properties.type-test.ts index 5800853c52..85f49fe819 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/extract-jsonb-properties.type-test.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/extract-jsonb-properties.type-test.ts @@ -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; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/jsonb-property.type-test.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/jsonb-property.type-test.ts index 996e4b4965..5a22925527 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/jsonb-property.type-test.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/jsonb-property.type-test.ts @@ -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 } }; diff --git a/packages/twenty-shared/src/types/EmptyObject.type.ts b/packages/twenty-shared/src/types/EmptyObject.type.ts new file mode 100644 index 0000000000..a11fe18d87 --- /dev/null +++ b/packages/twenty-shared/src/types/EmptyObject.type.ts @@ -0,0 +1,2 @@ +// oxlint-disable-next-line typescript/no-empty-object-type +export type EmptyObject = {}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 16d314c9f3..647a90a57b 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -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, diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 9fcddf8b2b..24fb003ecb 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/typed-object-entries.ts b/packages/twenty-shared/src/utils/typed-object-entries.ts new file mode 100644 index 0000000000..152b56da2b --- /dev/null +++ b/packages/twenty-shared/src/utils/typed-object-entries.ts @@ -0,0 +1,7 @@ +type StrictEntries = T extends unknown + ? { [K in keyof T]-?: [K, T[K]] }[keyof T] + : never; + +export const typedObjectEntries = >( + object: T, +): Array> => Object.entries(object) as Array>;