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:
+135
@@ -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;
|
||||
-14
@@ -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];
|
||||
+15
-11
@@ -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 };
|
||||
}
|
||||
|
||||
+6
-9
@@ -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;
|
||||
}
|
||||
|
||||
+9
-10
@@ -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 };
|
||||
}
|
||||
|
||||
+8
-6
@@ -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,
|
||||
|
||||
+3
-7
@@ -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 {
|
||||
|
||||
+25
-17
@@ -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,
|
||||
],
|
||||
|
||||
+62
-106
@@ -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 {
|
||||
|
||||
+108
@@ -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
|
||||
>
|
||||
>,
|
||||
];
|
||||
+13
@@ -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;
|
||||
+14
@@ -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>>
|
||||
>;
|
||||
+2
@@ -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';
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
@@ -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
-3
@@ -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
-3
@@ -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>>;
|
||||
Reference in New Issue
Block a user