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 33de8a3d38..065e4dc7c9 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 @@ -15,7 +15,7 @@ import { } from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface'; import { buildCurrentEncryptionKeyIdEnvelopeLikePattern } from 'src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util'; import { buildRotationErrorMessage } from 'src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util'; -import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; const ZERO_UUID = '00000000-0000-0000-0000-000000000000'; @@ -116,12 +116,9 @@ export class ColumnRotationSiteHandler< const rowId = row.id; const currentValue = row[encryptedColumn] as string | null | undefined; - if ( - !isDefined(currentValue) || - !currentValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) - ) { + if (!isDefined(currentValue) || !isEncryptedString(currentValue)) { this.logger.error( - `[${this.siteName}] row ${rowId}: column '${encryptedColumn}' is not a versioned envelope (expected '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}…'), refusing to rotate.`, + `[${this.siteName}] row ${rowId}: column '${encryptedColumn}' is not a versioned envelope, refusing to rotate.`, ); 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 cc452bd4f2..4563c4262a 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 @@ -13,7 +13,10 @@ import { } from 'src/database/commands/secret-encryption-rotation/interfaces/secret-encryption-rotation-handler.interface'; import { buildCurrentEncryptionKeyIdEnvelopeLikePattern } from 'src/database/commands/secret-encryption-rotation/utils/build-current-encryption-key-id-envelope-like-pattern.util'; import { buildRotationErrorMessage } from 'src/database/commands/secret-encryption-rotation/utils/build-rotation-error-message.util'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { + type EncryptedImapSmtpCaldavParams, + type ImapSmtpCaldavParams, +} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionException, @@ -133,10 +136,12 @@ export class ConnectionParametersRotationHandler extends SecretEncryptionRotatio connectionParameters, workspaceId, }: { - connectionParameters: ImapSmtpCaldavParams; + connectionParameters: EncryptedImapSmtpCaldavParams; workspaceId: string; - }): ImapSmtpCaldavParams { - const result: ImapSmtpCaldavParams = { ...connectionParameters }; + }): EncryptedImapSmtpCaldavParams { + const result: EncryptedImapSmtpCaldavParams = { + ...connectionParameters, + }; for (const protocol of ACCOUNT_TYPES) { const params = connectionParameters[protocol]; 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 3a94870814..193ec081b7 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 @@ -16,6 +16,7 @@ import { KeyValuePairEntity, KeyValuePairType, } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; @@ -111,14 +112,7 @@ export class SensitiveConfigStorageRotationHandler extends SecretEncryptionRotat }): Promise { const rawValue = row.value as unknown; - if ( - !isNonEmptyString(rawValue) || - !rawValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) - ) { - this.logger.error( - `[${this.siteName}] row ${row.id} (config key '${row.key}'): value is not a versioned envelope (expected '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}…'), refusing to rotate.`, - ); - + if (!isNonEmptyString(rawValue) || !isEncryptedString(rawValue)) { return { rotated: 0, skipped: 0, errors: 1 }; } diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts index 49ed1830db..ca23eb473f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts @@ -1,6 +1,7 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; @@ -26,7 +27,9 @@ const isPlaintext = (value: string | null): value is string => isDefined(value) && !value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX); @RegisteredInstanceCommand('2.5.0', 1798000004000, { type: 'slow' }) -export class EncryptConnectedAccountTokensSlowInstanceCommand implements SlowInstanceCommand { +export class EncryptConnectedAccountTokensSlowInstanceCommand + implements SlowInstanceCommand +{ constructor( private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService, ) {} @@ -59,7 +62,7 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand implements SlowIns if (isPlaintext(row.accessToken)) { params.push( this.connectedAccountTokenEncryptionService.encrypt({ - plaintext: row.accessToken, + plaintext: row.accessToken as PlaintextString, workspaceId: row.workspaceId, }), ); @@ -69,7 +72,7 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand implements SlowIns if (isPlaintext(row.refreshToken)) { params.push( this.connectedAccountTokenEncryptionService.encrypt({ - plaintext: row.refreshToken, + plaintext: row.refreshToken as PlaintextString, workspaceId: row.workspaceId, }), ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts index 44ad1f0452..0fb13b6a8a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts @@ -3,6 +3,9 @@ import { Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; @@ -34,7 +37,9 @@ const looksLikeLegacyCtrCiphertext = (value: string): boolean => LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value); @RegisteredInstanceCommand('2.5.0', 1798000005000, { type: 'slow' }) -export class EncryptApplicationVariableSlowInstanceCommand implements SlowInstanceCommand { +export class EncryptApplicationVariableSlowInstanceCommand + implements SlowInstanceCommand +{ private readonly logger = new Logger( EncryptApplicationVariableSlowInstanceCommand.name, ); @@ -72,12 +77,16 @@ export class EncryptApplicationVariableSlowInstanceCommand implements SlowInstan continue; } - let plaintext: string; + if (isEncryptedString(row.value)) { + continue; + } + + let plaintext: PlaintextString; if (looksLikeLegacyCtrCiphertext(row.value)) { try { plaintext = this.secretEncryptionService.decryptVersioned( - row.value, + row.value as EncryptedString, { workspaceId: row.workspaceId }, ); } catch (error) { @@ -86,13 +95,13 @@ export class EncryptApplicationVariableSlowInstanceCommand implements SlowInstan error instanceof Error ? error.message : String(error) }`, ); - plaintext = row.value; + plaintext = row.value as PlaintextString; } } else { this.logger.warn( `applicationVariable row ${row.id} value is not base64; treating as plaintext.`, ); - plaintext = row.value; + plaintext = row.value as PlaintextString; } if (!isDefined(plaintext)) { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts index 39131afc73..9630798f56 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts @@ -1,6 +1,8 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; @@ -19,7 +21,9 @@ type ApplicationRegistrationVariableRow = { }; @RegisteredInstanceCommand('2.5.0', 1798000006000, { type: 'slow' }) -export class EncryptApplicationRegistrationVariableSlowInstanceCommand implements SlowInstanceCommand { +export class EncryptApplicationRegistrationVariableSlowInstanceCommand + implements SlowInstanceCommand +{ constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} @@ -49,8 +53,12 @@ export class EncryptApplicationRegistrationVariableSlowInstanceCommand implement } for (const row of rows) { + if (isEncryptedString(row.encryptedValue)) { + continue; + } + const plaintext = this.secretEncryptionService.decryptVersioned( - row.encryptedValue, + row.encryptedValue as EncryptedString, ); if (!isDefined(plaintext)) { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts index 0c1875fe8b..fdc1e2c26c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts @@ -1,6 +1,9 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; @@ -47,8 +50,12 @@ export class EncryptSigningKeyPrivateKeysSlowInstanceCommand implements SlowInst } for (const row of rows) { + if (isEncryptedString(row.privateKey)) { + continue; + } + const plaintext = this.secretEncryptionService.decryptVersioned( - row.privateKey, + row.privateKey as EncryptedString, ); if (!isDefined(plaintext)) { @@ -56,7 +63,9 @@ export class EncryptSigningKeyPrivateKeysSlowInstanceCommand implements SlowInst } const encryptedPrivateKey = - this.secretEncryptionService.encryptVersioned(plaintext); + this.secretEncryptionService.encryptVersioned( + plaintext as PlaintextString, + ); await dataSource.query( `UPDATE "core"."signingKey" diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts index 52cf0414f1..220e0fbb53 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts @@ -2,7 +2,9 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { type ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; @@ -51,22 +53,22 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns continue; } - if ( - rawValue === '' || - rawValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) - ) { + if (rawValue === '' || isEncryptedString(rawValue)) { continue; } - const plaintext = - this.secretEncryptionService.decryptVersioned(rawValue); + const plaintext = this.secretEncryptionService.decryptVersioned( + rawValue as EncryptedString, + ); if (!isDefined(plaintext)) { continue; } const encrypted = - this.secretEncryptionService.encryptVersioned(plaintext); + this.secretEncryptionService.encryptVersioned( + plaintext as PlaintextString, + ); await dataSource.query( `UPDATE "core"."keyValuePair" diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts index 4ef30984d5..4a4da98a6c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts @@ -1,6 +1,7 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util'; @@ -59,7 +60,7 @@ export class EncryptTotpSecretsSlowInstanceCommand implements SlowInstanceComman } const encryptedValue = this.secretEncryptionService.encryptVersioned( - plaintext, + plaintext as PlaintextString, { workspaceId: row.workspaceId }, ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters.ts index 3b5862f6c1..f516d2cea8 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters.ts @@ -1,7 +1,12 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { + type EncryptedImapSmtpCaldavParams, + type ImapSmtpCaldavParams, + type PlaintextImapSmtpCaldavParams, +} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; @@ -16,6 +21,9 @@ const CHECK_CONSTRAINT_NAME = type ConnectionParametersRow = { id: string; workspaceId: string; + // Pre-backfill rows can hold either plaintext or already-v2 passwords. + // Either form is structurally a `string` at the JSONB layer; the brand + // type is a phantom marker only. connectionParameters: ImapSmtpCaldavParams | null; }; @@ -67,7 +75,7 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta continue; } - const plaintextOnly: ImapSmtpCaldavParams = {}; + const plaintextOnly: PlaintextImapSmtpCaldavParams = {}; for (const protocol of ACCOUNT_TYPES) { const protocolParams = row.connectionParameters[protocol]; @@ -78,7 +86,13 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX, ) ) { - plaintextOnly[protocol] = protocolParams; + // Upstream filter guarantees this protocol's password is + // plaintext (no `enc:v2:` prefix); brand the leaf in-place so + // the encryption service can consume it. + plaintextOnly[protocol] = { + ...protocolParams, + password: protocolParams.password as PlaintextString, + }; } } @@ -90,8 +104,12 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta }, ); - const merged: ImapSmtpCaldavParams = { - ...row.connectionParameters, + // Pre-backfill row may already contain a mix of plaintext and + // already-encrypted protocols; we trust the entity-level brand on + // the post-merge result since each protocol is either freshly + // encrypted above or was already an `enc:v2:` envelope. + const merged: EncryptedImapSmtpCaldavParams = { + ...(row.connectionParameters as EncryptedImapSmtpCaldavParams), ...encrypted, }; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable.ts new file mode 100644 index 0000000000..8d61032592 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable.ts @@ -0,0 +1,123 @@ +import { Logger } from '@nestjs/common'; + +import { DataSource, QueryRunner } from 'typeorm'; + +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; + +const BACKFILL_BATCH_SIZE = 500; + +const VALUE_CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted'; + +const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; + +type ApplicationVariableRow = { + id: string; + workspaceId: string; + value: string; +}; + +// Encrypts all remaining plaintext non-secret application variable rows +// into the enc:v2 envelope, then tightens the CHECK constraint to require +// encryption for ALL rows (not just isSecret=true ones). +@RegisteredInstanceCommand('2.9.0', 1798400000000, { type: 'slow' }) +export class EncryptNonSecretApplicationVariableSlowInstanceCommand + implements SlowInstanceCommand +{ + private readonly logger = new Logger( + EncryptNonSecretApplicationVariableSlowInstanceCommand.name, + ); + + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + async runDataMigration(dataSource: DataSource): Promise { + let cursor = '00000000-0000-0000-0000-000000000000'; + let totalEncrypted = 0; + + while (true) { + const rows: ApplicationVariableRow[] = await dataSource.query( + `SELECT id, "workspaceId", "value" + FROM "core"."applicationVariable" + WHERE id > $1 + AND "isSecret" = false + AND "value" <> '' + AND "value" NOT LIKE $2 + ORDER BY id + LIMIT $3`, + [cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE], + ); + + if (rows.length === 0) { + break; + } + + let batchEncrypted = 0; + + for (const row of rows) { + if (isEncryptedString(row.value)) { + continue; + } + + const encryptedValue = this.secretEncryptionService.encryptVersioned( + row.value as PlaintextString, + { workspaceId: row.workspaceId }, + ); + + await dataSource.query( + `UPDATE "core"."applicationVariable" + SET "value" = $2 + WHERE id = $1`, + [row.id, encryptedValue], + ); + + batchEncrypted++; + } + + totalEncrypted += batchEncrypted; + cursor = rows[rows.length - 1].id; + + this.logger.log( + `Encrypted ${batchEncrypted} non-secret application variables in this batch (${totalEncrypted} total so far)`, + ); + } + + this.logger.log( + `Finished encrypting non-secret application variables: ${totalEncrypted} rows encrypted`, + ); + } + + // Tightens the CHECK constraint: all values must now be either empty + // or in the enc:v2 envelope, regardless of isSecret. + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" + DROP CONSTRAINT IF EXISTS "${VALUE_CHECK_CONSTRAINT_NAME}"`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" + ADD CONSTRAINT "${VALUE_CHECK_CONSTRAINT_NAME}" + CHECK ("value" = '' OR "value" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" + DROP CONSTRAINT IF EXISTS "${VALUE_CHECK_CONSTRAINT_NAME}"`, + ); + + // Restore the original constraint that allowed plaintext for non-secret rows + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" + ADD CONSTRAINT "${VALUE_CHECK_CONSTRAINT_NAME}" + CHECK ("isSecret" = false OR "value" = '' OR "value" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index d3971bb98d..65514a2900 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -22,8 +22,6 @@ import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application'; import { AddSubFieldNameToViewSortEarlyFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort'; import { AddRelationTargetFieldMetadataIdToViewFilterEarlyFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1747234300000-add-relation-target-field-metadata-id-to-view-filter'; -import { AddRelationTargetFieldMetadataIdToViewFilterEarly2_4FastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1747234400000-add-relation-target-field-metadata-id-to-view-filter'; -import { AddRelationTargetFieldMetadataIdToViewFilterEarly2_5FastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1747234500000-add-relation-target-field-metadata-id-to-view-filter'; import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777308014234-add-upgrade-migration-workspace-id-index'; import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread'; import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity'; @@ -32,30 +30,33 @@ import { TransformApplicationVariableToSyncableEntityFastInstanceCommand } from import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1797000001000-add-tool-and-workflow-action-trigger-settings'; import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier'; import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings'; +import { AddRelationTargetFieldMetadataIdToViewFilterEarly2_4FastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1747234400000-add-relation-target-field-metadata-id-to-view-filter'; import { AddMetadataToBillingPriceFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1777100000000-add-metadata-to-billing-price'; -import { RenamePermissionFlagToRolePermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340020-rename-permission-flag-to-role-permission-flag'; -import { PermissionFlagSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340021-permission-flag-syncable-entity'; -import { LinkRolePermissionFlagToPermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340022-link-role-permission-flag-to-permission-flag'; -import { BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-slow-1778235340023-backfill-role-permission-flag-permission-flag-id'; import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type'; import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain'; +import { AddRelationTargetFieldMetadataIdToViewFilterEarly2_5FastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1747234500000-add-relation-target-field-metadata-id-to-view-filter'; +import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort'; import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled'; import { CreateSigningKeyTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table'; +import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table'; import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; -import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter'; -import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000020000-emailing-domain-tenant-status-and-global-uniqueness'; import { EncryptApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable'; import { EncryptApplicationRegistrationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable'; import { EncryptSigningKeyPrivateKeysSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys'; import { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage'; import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets'; -import { EncryptConnectionParametersSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters'; -import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort'; -import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table'; +import { RenamePermissionFlagToRolePermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340020-rename-permission-flag-to-role-permission-flag'; +import { PermissionFlagSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340021-permission-flag-syncable-entity'; +import { LinkRolePermissionFlagToPermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340022-link-role-permission-flag-to-permission-flag'; +import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter'; import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes'; +import { BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-slow-1778235340023-backfill-role-permission-flag-permission-flag-id'; import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover'; +import { EncryptConnectionParametersSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters'; import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata'; import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column'; +import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000020000-emailing-domain-tenant-status-and-global-uniqueness'; +import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable'; import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences'; export const INSTANCE_COMMANDS = [ @@ -116,4 +117,5 @@ export const INSTANCE_COMMANDS = [ AddSubFieldNameToIndexFieldMetadataFastInstanceCommand, DropFieldMetadataIsUniqueColumnFastInstanceCommand, MigrateAiModelPreferencesSlowInstanceCommand, + EncryptNonSecretApplicationVariableSlowInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts index 007d228354..a37da1a386 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts @@ -4,8 +4,8 @@ import { type Manifest } from 'twenty-shared/application'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; +import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service'; import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; -import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util'; import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { ApplicationException, @@ -31,6 +31,7 @@ export class ApplicationManifestMigrationService { private readonly workspaceCacheService: WorkspaceCacheService, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly applicationService: ApplicationService, + private readonly computeManifestFlatEntityMapsService: ComputeApplicationManifestAllUniversalFlatEntityMapsService, ) {} async syncPreInstallLogicFunctionFromManifest({ @@ -106,10 +107,11 @@ export class ApplicationManifestMigrationService { }); const toAllUniversalFlatEntityMaps = - computeApplicationManifestAllUniversalFlatEntityMaps({ + this.computeManifestFlatEntityMapsService.compute({ manifest: preInstallOnlyManifest, ownerFlatApplication, now, + workspaceId, }); const dependencyAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({ @@ -190,10 +192,11 @@ export class ApplicationManifestMigrationService { }); const toAllUniversalFlatEntityMaps = - computeApplicationManifestAllUniversalFlatEntityMaps({ + this.computeManifestFlatEntityMapsService.compute({ manifest, ownerFlatApplication, now, + workspaceId, }); const dependencyAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts index 1d3d9752a1..04c0b162ff 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts @@ -3,10 +3,12 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service'; import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver'; +import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; @@ -20,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace FeatureFlagModule, FileStorageModule, PermissionsModule, + SecretEncryptionModule, WorkspaceCacheModule, WorkspaceMigrationModule, WorkspaceMigrationRunnerModule, @@ -28,6 +31,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ApplicationManifestMigrationService, ApplicationManifestResolver, ApplicationSyncService, + ComputeApplicationManifestAllUniversalFlatEntityMapsService, WorkspaceMigrationGraphqlApiExceptionInterceptor, ], exports: [ApplicationManifestMigrationService, ApplicationSyncService], diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util.ts index 72b5a8bf3e..b253667c26 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util.ts @@ -1,3 +1,4 @@ +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type'; export const fromApplicationVariableManifestToUniversalFlatApplicationVariable = @@ -5,7 +6,7 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable = key, universalIdentifier, description, - value, + encryptedValue, isSecret, applicationUniversalIdentifier, now, @@ -13,7 +14,7 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable = key: string; universalIdentifier: string; description?: string; - value?: string; + encryptedValue: EncryptedString | ''; isSecret?: boolean; applicationUniversalIdentifier: string; now: string; @@ -22,7 +23,7 @@ export const fromApplicationVariableManifestToUniversalFlatApplicationVariable = universalIdentifier, applicationUniversalIdentifier, key, - value: isSecret ? '' : (value ?? ''), // We protect secret variable by not syncing its value at all + value: encryptedValue, description: description ?? '', isSecret: isSecret ?? false, createdAt: now, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts new file mode 100644 index 0000000000..29c6e9ec14 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts @@ -0,0 +1,621 @@ +import { Injectable } from '@nestjs/common'; + +import { type Manifest } from 'twenty-shared/application'; +import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util'; +import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; +import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util'; +import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util'; +import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util'; +import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util'; +import { fromIndexManifestToUniversalFlatIndex } from 'src/engine/core-modules/application/application-manifest/converters/from-index-manifest-to-universal-flat-index.util'; +import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util'; +import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util'; +import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util'; +import { fromObjectPermissionManifestToUniversalFlatObjectPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-object-permission-manifest-to-universal-flat-object-permission.util'; +import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util'; +import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util'; +import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util'; +import { fromPermissionFlagManifestToUniversalFlatPermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-manifest-to-universal-flat-permission-flag.util'; +import { fromPermissionFlagToUniversalFlatRolePermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-to-universal-flat-role-permission-flag.util'; +import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util'; +import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util'; +import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util'; +import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util'; +import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util'; +import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util'; +import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util'; +import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util'; +import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util'; +import { fromViewSortManifestToUniversalFlatViewSort } from 'src/engine/core-modules/application/application-manifest/converters/from-view-sort-manifest-to-universal-flat-view-sort.util'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { fromAgentManifestToUniversalFlatAgent } from 'src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; +import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +export class ComputeApplicationManifestAllUniversalFlatEntityMapsService { + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + private encryptApplicationVariableValue( + plaintext: string, + workspaceId: string, + ): EncryptedString | '' { + if (plaintext === '') { + return ''; + } + + return this.secretEncryptionService.encryptVersioned( + plaintext as PlaintextString, + { workspaceId }, + ); + } + + compute({ + manifest, + ownerFlatApplication, + now, + workspaceId, + }: { + manifest: Manifest; + ownerFlatApplication: FlatApplication; + now: string; + workspaceId: string; + }): AllFlatEntityMaps { + const allUniversalFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + const { universalIdentifier: applicationUniversalIdentifier } = + ownerFlatApplication; + + for (const objectManifest of manifest.objects) { + const flatObjectMetadata = + fromObjectManifestToUniversalFlatObjectMetadata({ + objectManifest, + applicationUniversalIdentifier, + now, + }); + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: flatObjectMetadata, + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatObjectMetadataMaps, + }); + + for (const fieldManifest of objectManifest.fields) { + const enrichedFieldManifest = + fieldManifest.type === FieldMetadataType.TS_VECTOR && + !isDefined(fieldManifest.universalSettings) + ? { + ...fieldManifest, + objectUniversalIdentifier: objectManifest.universalIdentifier, + universalSettings: + computeSearchVectorUniversalSettingsFromObjectManifest({ + objectManifest, + }), + } + : { + ...fieldManifest, + objectUniversalIdentifier: objectManifest.universalIdentifier, + }; + + const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata( + { + fieldManifest: enrichedFieldManifest, + applicationUniversalIdentifier, + now, + }, + ); + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: flatFieldMetadata, + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatFieldMetadataMaps, + }); + + if (flatFieldMetadata.isUnique) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow( + { + universalFlatEntity: generateIndexForFlatFieldMetadata({ + flatFieldMetadata, + flatObjectMetadata, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatIndexMaps, + }, + ); + } + } + } + + for (const fieldManifest of manifest.fields) { + const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: fieldManifest, + applicationUniversalIdentifier, + now, + }); + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: flatFieldMetadata, + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatFieldMetadataMaps, + }); + + if (flatFieldMetadata.isUnique) { + const flatObjectMetadata = + allUniversalFlatEntityMaps.flatObjectMetadataMaps + .byUniversalIdentifier[ + flatFieldMetadata.objectMetadataUniversalIdentifier + ]; + + if (isDefined(flatObjectMetadata)) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow( + { + universalFlatEntity: generateIndexForFlatFieldMetadata({ + flatFieldMetadata, + flatObjectMetadata, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatIndexMaps, + }, + ); + } + } + } + + const indexCountByObjectUniversalIdentifier = new Map(); + const fieldsByObjectUniversalIdentifier = new Map< + string, + UniversalFlatFieldMetadata[] + >(); + + for (const flatField of Object.values( + allUniversalFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(flatField)) continue; + + const bucket = + fieldsByObjectUniversalIdentifier.get( + flatField.objectMetadataUniversalIdentifier, + ) ?? []; + + if (bucket.length === 0) { + fieldsByObjectUniversalIdentifier.set( + flatField.objectMetadataUniversalIdentifier, + bucket, + ); + } + + bucket.push(flatField); + } + + for (const indexManifest of manifest.indexes ?? []) { + const flatObjectMetadata = + allUniversalFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[ + indexManifest.objectUniversalIdentifier + ]; + + if (!isDefined(flatObjectMetadata)) { + throw new Error( + `Index "${indexManifest.universalIdentifier}" references unknown object ${indexManifest.objectUniversalIdentifier}`, + ); + } + + const nextCount = + (indexCountByObjectUniversalIdentifier.get( + indexManifest.objectUniversalIdentifier, + ) ?? 0) + 1; + + if (nextCount > MAX_CUSTOM_INDEXES_PER_OBJECT) { + throw new Error( + `Application declares more than ${MAX_CUSTOM_INDEXES_PER_OBJECT} indexes on object ${indexManifest.objectUniversalIdentifier}`, + ); + } + + indexCountByObjectUniversalIdentifier.set( + indexManifest.objectUniversalIdentifier, + nextCount, + ); + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromIndexManifestToUniversalFlatIndex({ + indexManifest, + flatObjectMetadata, + objectFlatFieldMetadatas: + fieldsByObjectUniversalIdentifier.get( + flatObjectMetadata.universalIdentifier, + ) ?? [], + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatIndexMaps, + }); + } + + for (const logicFunctionManifest of manifest.logicFunctions) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromLogicFunctionManifestToUniversalFlatLogicFunction({ + logicFunctionManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatLogicFunctionMaps, + }); + } + + for (const frontComponentManifest of manifest.frontComponents) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromFrontComponentManifestToUniversalFlatFrontComponent({ + frontComponentManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatFrontComponentMaps, + }); + } + + for (const connectionProviderManifest of manifest.connectionProviders ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromConnectionProviderManifestToUniversalFlatConnectionProvider({ + connectionProviderManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatConnectionProviderMaps, + }); + } + + for (const permissionFlagManifest of manifest.permissionFlags ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromPermissionFlagManifestToUniversalFlatPermissionFlag({ + permissionFlagManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPermissionFlagMaps, + }); + } + + for (const roleManifest of manifest.roles) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromRoleManifestToUniversalFlatRole({ + roleManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatRoleMaps, + }); + for (const objectPermissionManifest of roleManifest.objectPermissions ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromObjectPermissionManifestToUniversalFlatObjectPermission({ + objectPermissionManifest, + roleUniversalIdentifier: roleManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatObjectPermissionMaps, + }); + } + + for (const fieldPermissionManifest of roleManifest.fieldPermissions ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromFieldPermissionManifestToUniversalFlatFieldPermission({ + fieldPermissionManifest, + roleUniversalIdentifier: roleManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatFieldPermissionMaps, + }); + } + + for (const permissionFlagUniversalIdentifier of roleManifest.permissionFlagUniversalIdentifiers ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromPermissionFlagToUniversalFlatRolePermissionFlag({ + permissionFlagUniversalIdentifier, + roleUniversalIdentifier: roleManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatRolePermissionFlagMaps, + }); + } + } + + for (const skillManifest of manifest.skills ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromSkillManifestToUniversalFlatSkill({ + skillManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatSkillMaps, + }); + } + + for (const agentManifest of manifest.agents ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromAgentManifestToUniversalFlatAgent({ + agentManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatAgentMaps, + }); + } + + for (const viewManifest of manifest.views ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewManifestToUniversalFlatView({ + viewManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewMaps, + }); + + for (const viewFieldGroupManifest of viewManifest.fieldGroups ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromViewFieldGroupManifestToUniversalFlatViewFieldGroup({ + viewFieldGroupManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewFieldGroupMaps, + }); + } + + for (const viewFieldManifest of viewManifest.fields ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewFieldManifestToUniversalFlatViewField({ + viewFieldManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewFieldMaps, + }); + } + + for (const viewFilterGroupManifest of viewManifest.filterGroups ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromViewFilterGroupManifestToUniversalFlatViewFilterGroup({ + viewFilterGroupManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewFilterGroupMaps, + }); + } + + for (const viewFilterManifest of viewManifest.filters ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewFilterManifestToUniversalFlatViewFilter({ + viewFilterManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewFilterMaps, + }); + } + + for (const viewGroupManifest of viewManifest.groups ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewGroupManifestToUniversalFlatViewGroup({ + viewGroupManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewGroupMaps, + }); + } + + for (const viewSortManifest of viewManifest.sorts ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewSortManifestToUniversalFlatViewSort({ + viewSortManifest, + viewUniversalIdentifier: viewManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewSortMaps, + }); + } + } + + for (const navigationMenuItemManifest of manifest.navigationMenuItems ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({ + navigationMenuItemManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatNavigationMenuItemMaps, + }); + } + + for (const pageLayoutManifest of manifest.pageLayouts ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromPageLayoutManifestToUniversalFlatPageLayout({ + pageLayoutManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPageLayoutMaps, + }); + + for (const pageLayoutTabManifest of pageLayoutManifest.tabs ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({ + pageLayoutTabManifest, + pageLayoutUniversalIdentifier: + pageLayoutManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPageLayoutTabMaps, + }); + + for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow( + { + universalFlatEntity: + fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({ + pageLayoutWidgetManifest, + pageLayoutTabUniversalIdentifier: + pageLayoutTabManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps, + }, + ); + } + } + } + + for (const pageLayoutTabManifest of manifest.pageLayoutTabs ?? []) { + if (!isDefined(pageLayoutTabManifest.pageLayoutUniversalIdentifier)) { + throw new Error( + `Top-level pageLayoutTab "${pageLayoutTabManifest.universalIdentifier}" is missing required pageLayoutUniversalIdentifier`, + ); + } + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({ + pageLayoutTabManifest, + pageLayoutUniversalIdentifier: + pageLayoutTabManifest.pageLayoutUniversalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPageLayoutTabMaps, + }); + + for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({ + pageLayoutWidgetManifest, + pageLayoutTabUniversalIdentifier: + pageLayoutTabManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps, + }); + } + } + + for (const [key, applicationVariableManifest] of Object.entries( + manifest.application.applicationVariables ?? {}, + )) { + const plaintextValue = + 'value' in applicationVariableManifest + ? applicationVariableManifest.value + : undefined; + + const isSecret = applicationVariableManifest.isSecret; + const rawValue = isSecret ? '' : (plaintextValue ?? ''); + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromApplicationVariableManifestToUniversalFlatApplicationVariable({ + key, + universalIdentifier: + applicationVariableManifest.universalIdentifier, + encryptedValue: this.encryptApplicationVariableValue( + rawValue, + workspaceId, + ), + description: applicationVariableManifest.description, + isSecret, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatApplicationVariableMaps, + }); + } + + for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) { + if ( + !isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier) + ) { + throw new Error( + `Top-level commandMenuItem "${commandMenuItemManifest.universalIdentifier}" is missing required frontComponentUniversalIdentifier`, + ); + } + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({ + commandMenuItemManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatCommandMenuItemMaps, + }); + } + + return allUniversalFlatEntityMaps; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts deleted file mode 100644 index 33cb027b3a..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { type Manifest } from 'twenty-shared/application'; -import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; - -import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; - -import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util'; -import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; -import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util'; -import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util'; -import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util'; -import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util'; -import { fromIndexManifestToUniversalFlatIndex } from 'src/engine/core-modules/application/application-manifest/converters/from-index-manifest-to-universal-flat-index.util'; -import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util'; -import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util'; -import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util'; -import { fromObjectPermissionManifestToUniversalFlatObjectPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-object-permission-manifest-to-universal-flat-object-permission.util'; -import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util'; -import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util'; -import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util'; -import { fromPermissionFlagManifestToUniversalFlatPermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-manifest-to-universal-flat-permission-flag.util'; -import { fromPermissionFlagToUniversalFlatRolePermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-to-universal-flat-role-permission-flag.util'; -import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util'; -import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util'; -import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util'; -import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util'; -import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util'; -import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util'; -import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util'; -import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util'; -import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util'; -import { fromViewSortManifestToUniversalFlatViewSort } from 'src/engine/core-modules/application/application-manifest/converters/from-view-sort-manifest-to-universal-flat-view-sort.util'; -import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { fromAgentManifestToUniversalFlatAgent } from 'src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util'; -import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; -import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; -import { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-maps-through-mutation-or-throw.util'; - -export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ - manifest, - ownerFlatApplication, - now, -}: { - manifest: Manifest; - ownerFlatApplication: FlatApplication; - now: string; -}): AllFlatEntityMaps => { - const allUniversalFlatEntityMaps = createEmptyAllFlatEntityMaps(); - - const { universalIdentifier: applicationUniversalIdentifier } = - ownerFlatApplication; - - for (const objectManifest of manifest.objects) { - const flatObjectMetadata = fromObjectManifestToUniversalFlatObjectMetadata({ - objectManifest, - applicationUniversalIdentifier, - now, - }); - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: flatObjectMetadata, - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatObjectMetadataMaps, - }); - - for (const fieldManifest of objectManifest.fields) { - const enrichedFieldManifest = - fieldManifest.type === FieldMetadataType.TS_VECTOR && - !isDefined(fieldManifest.universalSettings) - ? { - ...fieldManifest, - objectUniversalIdentifier: objectManifest.universalIdentifier, - universalSettings: - computeSearchVectorUniversalSettingsFromObjectManifest({ - objectManifest, - }), - } - : { - ...fieldManifest, - objectUniversalIdentifier: objectManifest.universalIdentifier, - }; - - const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata({ - fieldManifest: enrichedFieldManifest, - applicationUniversalIdentifier, - now, - }); - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: flatFieldMetadata, - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatFieldMetadataMaps, - }); - - if (flatFieldMetadata.isUnique) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: generateIndexForFlatFieldMetadata({ - flatFieldMetadata, - flatObjectMetadata, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatIndexMaps, - }); - } - } - } - - for (const fieldManifest of manifest.fields) { - const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata({ - fieldManifest: fieldManifest, - applicationUniversalIdentifier, - now, - }); - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: flatFieldMetadata, - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatFieldMetadataMaps, - }); - - if (flatFieldMetadata.isUnique) { - const flatObjectMetadata = - allUniversalFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[ - flatFieldMetadata.objectMetadataUniversalIdentifier - ]; - - if (isDefined(flatObjectMetadata)) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: generateIndexForFlatFieldMetadata({ - flatFieldMetadata, - flatObjectMetadata, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatIndexMaps, - }); - } - } - } - - const indexCountByObjectUniversalIdentifier = new Map(); - const fieldsByObjectUniversalIdentifier = new Map< - string, - UniversalFlatFieldMetadata[] - >(); - - for (const flatField of Object.values( - allUniversalFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier, - )) { - if (!isDefined(flatField)) continue; - - const bucket = - fieldsByObjectUniversalIdentifier.get( - flatField.objectMetadataUniversalIdentifier, - ) ?? []; - - if (bucket.length === 0) { - fieldsByObjectUniversalIdentifier.set( - flatField.objectMetadataUniversalIdentifier, - bucket, - ); - } - - bucket.push(flatField); - } - - for (const indexManifest of manifest.indexes ?? []) { - const flatObjectMetadata = - allUniversalFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[ - indexManifest.objectUniversalIdentifier - ]; - - if (!isDefined(flatObjectMetadata)) { - throw new Error( - `Index "${indexManifest.universalIdentifier}" references unknown object ${indexManifest.objectUniversalIdentifier}`, - ); - } - - const nextCount = - (indexCountByObjectUniversalIdentifier.get( - indexManifest.objectUniversalIdentifier, - ) ?? 0) + 1; - - if (nextCount > MAX_CUSTOM_INDEXES_PER_OBJECT) { - throw new Error( - `Application declares more than ${MAX_CUSTOM_INDEXES_PER_OBJECT} indexes on object ${indexManifest.objectUniversalIdentifier}`, - ); - } - - indexCountByObjectUniversalIdentifier.set( - indexManifest.objectUniversalIdentifier, - nextCount, - ); - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromIndexManifestToUniversalFlatIndex({ - indexManifest, - flatObjectMetadata, - objectFlatFieldMetadatas: - fieldsByObjectUniversalIdentifier.get( - flatObjectMetadata.universalIdentifier, - ) ?? [], - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatIndexMaps, - }); - } - - for (const logicFunctionManifest of manifest.logicFunctions) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromLogicFunctionManifestToUniversalFlatLogicFunction({ - logicFunctionManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatLogicFunctionMaps, - }); - } - - for (const frontComponentManifest of manifest.frontComponents) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromFrontComponentManifestToUniversalFlatFrontComponent({ - frontComponentManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatFrontComponentMaps, - }); - } - - for (const connectionProviderManifest of manifest.connectionProviders ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromConnectionProviderManifestToUniversalFlatConnectionProvider({ - connectionProviderManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatConnectionProviderMaps, - }); - } - - for (const permissionFlagManifest of manifest.permissionFlags ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPermissionFlagManifestToUniversalFlatPermissionFlag({ - permissionFlagManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPermissionFlagMaps, - }); - } - - for (const roleManifest of manifest.roles) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromRoleManifestToUniversalFlatRole({ - roleManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatRoleMaps, - }); - for (const objectPermissionManifest of roleManifest.objectPermissions ?? - []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromObjectPermissionManifestToUniversalFlatObjectPermission({ - objectPermissionManifest, - roleUniversalIdentifier: roleManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatObjectPermissionMaps, - }); - } - - for (const fieldPermissionManifest of roleManifest.fieldPermissions ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromFieldPermissionManifestToUniversalFlatFieldPermission({ - fieldPermissionManifest, - roleUniversalIdentifier: roleManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatFieldPermissionMaps, - }); - } - - for (const permissionFlagUniversalIdentifier of roleManifest.permissionFlagUniversalIdentifiers ?? - []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPermissionFlagToUniversalFlatRolePermissionFlag({ - permissionFlagUniversalIdentifier, - roleUniversalIdentifier: roleManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatRolePermissionFlagMaps, - }); - } - } - - for (const skillManifest of manifest.skills ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromSkillManifestToUniversalFlatSkill({ - skillManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatSkillMaps, - }); - } - - for (const agentManifest of manifest.agents ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromAgentManifestToUniversalFlatAgent({ - agentManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatAgentMaps, - }); - } - - for (const viewManifest of manifest.views ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromViewManifestToUniversalFlatView({ - viewManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatViewMaps, - }); - - for (const viewFieldGroupManifest of viewManifest.fieldGroups ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromViewFieldGroupManifestToUniversalFlatViewFieldGroup({ - viewFieldGroupManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewFieldGroupMaps, - }); - } - - for (const viewFieldManifest of viewManifest.fields ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromViewFieldManifestToUniversalFlatViewField({ - viewFieldManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewFieldMaps, - }); - } - - for (const viewFilterGroupManifest of viewManifest.filterGroups ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromViewFilterGroupManifestToUniversalFlatViewFilterGroup({ - viewFilterGroupManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewFilterGroupMaps, - }); - } - - for (const viewFilterManifest of viewManifest.filters ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromViewFilterManifestToUniversalFlatViewFilter({ - viewFilterManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewFilterMaps, - }); - } - - for (const viewGroupManifest of viewManifest.groups ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromViewGroupManifestToUniversalFlatViewGroup({ - viewGroupManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewGroupMaps, - }); - } - - for (const viewSortManifest of viewManifest.sorts ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromViewSortManifestToUniversalFlatViewSort({ - viewSortManifest, - viewUniversalIdentifier: viewManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatViewSortMaps, - }); - } - } - - for (const navigationMenuItemManifest of manifest.navigationMenuItems ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({ - navigationMenuItemManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatNavigationMenuItemMaps, - }); - } - - for (const pageLayoutManifest of manifest.pageLayouts ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromPageLayoutManifestToUniversalFlatPageLayout({ - pageLayoutManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPageLayoutMaps, - }); - - for (const pageLayoutTabManifest of pageLayoutManifest.tabs ?? []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({ - pageLayoutTabManifest, - pageLayoutUniversalIdentifier: - pageLayoutManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPageLayoutTabMaps, - }); - - for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ?? - []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({ - pageLayoutWidgetManifest, - pageLayoutTabUniversalIdentifier: - pageLayoutTabManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps, - }); - } - } - } - - for (const pageLayoutTabManifest of manifest.pageLayoutTabs ?? []) { - if (!isDefined(pageLayoutTabManifest.pageLayoutUniversalIdentifier)) { - throw new Error( - `Top-level pageLayoutTab "${pageLayoutTabManifest.universalIdentifier}" is missing required pageLayoutUniversalIdentifier`, - ); - } - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({ - pageLayoutTabManifest, - pageLayoutUniversalIdentifier: - pageLayoutTabManifest.pageLayoutUniversalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPageLayoutTabMaps, - }); - - for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ?? - []) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({ - pageLayoutWidgetManifest, - pageLayoutTabUniversalIdentifier: - pageLayoutTabManifest.universalIdentifier, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps, - }); - } - } - - for (const [key, applicationVariableManifest] of Object.entries( - manifest.application.applicationVariables ?? {}, - )) { - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromApplicationVariableManifestToUniversalFlatApplicationVariable({ - key, - universalIdentifier: applicationVariableManifest.universalIdentifier, - value: - 'value' in applicationVariableManifest - ? applicationVariableManifest.value - : undefined, - description: applicationVariableManifest.description, - isSecret: applicationVariableManifest.isSecret, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatApplicationVariableMaps, - }); - } - - for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) { - if (!isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier)) { - throw new Error( - `Top-level commandMenuItem "${commandMenuItemManifest.universalIdentifier}" is missing required frontComponentUniversalIdentifier`, - ); - } - - addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: - fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({ - commandMenuItemManifest, - applicationUniversalIdentifier, - now, - }), - universalFlatEntityMapsToMutate: - allUniversalFlatEntityMaps.flatCommandMenuItemMaps, - }); - } - - return allUniversalFlatEntityMaps; -}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity.ts index 90d1084e45..e51c662173 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity.ts @@ -17,6 +17,7 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; @Entity({ name: 'applicationRegistrationVariable', schema: 'core' }) @ObjectType('ApplicationRegistrationVariable') @@ -42,7 +43,7 @@ export class ApplicationRegistrationVariableEntity { key: string; @Column({ nullable: false, type: 'text', default: '' }) - encryptedValue: string; + encryptedValue: EncryptedString | ''; @Field() @Column({ nullable: false, type: 'text', default: '' }) diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.service.ts index 9a81846681..a7bc4a9c76 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/application-registration-variable.service.ts @@ -48,15 +48,20 @@ export class ApplicationRegistrationVariableService { order: { key: 'ASC' }, }); - return variables.map((variable) => ({ - ...variable, - isFilled: variable.isFilled, - value: variable.isFilled - ? variable.isSecret - ? '•••••••••••••' - : this.encryptionService.decryptVersioned(variable.encryptedValue) - : null, - })); + return variables.map((variable) => { + const { encryptedValue } = variable; + + return { + ...variable, + isFilled: variable.isFilled, + value: + encryptedValue !== '' + ? variable.isSecret + ? '•••••••••••••' + : this.encryptionService.decryptVersioned(encryptedValue) + : null, + }; + }); } async createVariable( diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input.ts index 37c6906d4c..b26aa134c3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input.ts @@ -8,6 +8,8 @@ import { MaxLength, } from 'class-validator'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; + @InputType() export class CreateApplicationRegistrationVariableInput { @Field() @@ -19,10 +21,10 @@ export class CreateApplicationRegistrationVariableInput { @MaxLength(256) key: string; - @Field() + @Field(() => String) @IsString() @MaxLength(10000) - value: string; + value: PlaintextString; @Field({ nullable: true }) @IsString() diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input.ts index ab9c8b3580..469fd6d9d5 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input.ts @@ -10,13 +10,15 @@ import { ValidateNested, } from 'class-validator'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; + @InputType() export class UpdateApplicationRegistrationVariablePayload { - @Field({ nullable: true }) + @Field(() => String, { nullable: true }) @IsString() @MaxLength(10000) @IsOptional() - value?: string; + value?: PlaintextString; @Field({ nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts index 3ac93232dd..3c4191b7f4 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts @@ -10,6 +10,7 @@ import { } from 'src/engine/core-modules/application/application-variable/application-variable.exception'; import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service'; import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -96,7 +97,7 @@ describe('ApplicationVariableEntityService', () => { await service.update({ key: 'API_KEY', - plainTextValue: 'new-secret-value', + plainTextValue: 'new-secret-value' as PlaintextString, applicationId: mockApplicationId, workspaceId: mockWorkspaceId, }); @@ -115,7 +116,7 @@ describe('ApplicationVariableEntityService', () => { ); }); - it('should not encrypt value when variable is not secret', async () => { + it('should encrypt value even when variable is not secret', async () => { const existingVariable = { id: '1', key: 'PUBLIC_URL', @@ -129,15 +130,18 @@ describe('ApplicationVariableEntityService', () => { await service.update({ key: 'PUBLIC_URL', - plainTextValue: 'https://new-url.com', + plainTextValue: 'https://new-url.com' as PlaintextString, applicationId: mockApplicationId, workspaceId: mockWorkspaceId, }); - expect(secretEncryptionService.encryptVersioned).not.toHaveBeenCalled(); + expect(secretEncryptionService.encryptVersioned).toHaveBeenCalledWith( + 'https://new-url.com', + { workspaceId: mockWorkspaceId }, + ); expect(repository.update).toHaveBeenCalledWith( { key: 'PUBLIC_URL', applicationId: mockApplicationId }, - { value: 'https://new-url.com' }, + { value: `enc:v2:deadbeef:https://new-url.com|${mockWorkspaceId}` }, ); }); @@ -147,7 +151,7 @@ describe('ApplicationVariableEntityService', () => { await expect( service.update({ key: 'NON_EXISTENT', - plainTextValue: 'some-value', + plainTextValue: 'some-value' as PlaintextString, applicationId: mockApplicationId, workspaceId: mockWorkspaceId, }), @@ -156,7 +160,7 @@ describe('ApplicationVariableEntityService', () => { await expect( service.update({ key: 'NON_EXISTENT', - plainTextValue: 'some-value', + plainTextValue: 'some-value' as PlaintextString, applicationId: mockApplicationId, workspaceId: mockWorkspaceId, }), diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.entity.ts index 9000ab2226..ac19168543 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity({ @@ -18,13 +19,11 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti schema: 'core', }) @ObjectType('ApplicationVariable') -// Constrains `value` for secret rows to the versioned envelope, while -// leaving plaintext non-secret values untouched. The keyId portion is -// not constrained so future ENCRYPTION_KEY rotations do not need a DDL -// migration. +// All values are always encrypted regardless of `isSecret`. The +// `isSecret` flag only controls display behavior (masked vs plaintext). @Check( 'CHK_applicationVariable_value_encrypted', - `"isSecret" = false OR "value" = '' OR "value" LIKE 'enc:v2:%'`, + `"value" = '' OR "value" LIKE 'enc:v2:%'`, ) export class ApplicationVariableEntity extends SyncableEntity { @IDField(() => UUIDScalarType) @@ -35,7 +34,7 @@ export class ApplicationVariableEntity extends SyncableEntity { key: string; @Column({ nullable: false, type: 'text', default: '' }) - value: string; + value: EncryptedString | ''; @Column({ nullable: false, type: 'text', default: '' }) description: string; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts index 0382054101..8a36e22402 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts @@ -10,9 +10,9 @@ import { ApplicationVariableEntityExceptionCode, } from 'src/engine/core-modules/application/application-variable/application-variable.exception'; import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { isNonEmptyString } from '@sniptt/guards'; @Injectable() export class ApplicationVariableEntityService { @@ -24,19 +24,22 @@ export class ApplicationVariableEntityService { ) {} getDisplayValue(applicationVariable: ApplicationVariableEntity): string { - if (!applicationVariable.isSecret) { - return applicationVariable.value; - } - - if (!isNonEmptyString(applicationVariable.value)) { + if (applicationVariable.value === '') { return ''; } - return this.secretEncryptionService.decryptAndMaskVersioned({ - value: applicationVariable.value, - mask: SECRET_APPLICATION_VARIABLE_MASK, - workspaceId: applicationVariable.workspaceId, - }); + if (applicationVariable.isSecret) { + return this.secretEncryptionService.decryptAndMaskVersioned({ + value: applicationVariable.value, + mask: SECRET_APPLICATION_VARIABLE_MASK, + workspaceId: applicationVariable.workspaceId, + }); + } + + return this.secretEncryptionService.decryptVersioned( + applicationVariable.value, + { workspaceId: applicationVariable.workspaceId }, + ); } async update({ @@ -47,7 +50,7 @@ export class ApplicationVariableEntityService { }: Pick & { applicationId: string; workspaceId: string; - plainTextValue: string; + plainTextValue: PlaintextString; }) { const existingVariable = await this.applicationVariableRepository.findOne({ where: { key, applicationId }, @@ -60,16 +63,12 @@ export class ApplicationVariableEntityService { ); } - const encryptedValue = existingVariable.isSecret - ? this.secretEncryptionService.encryptVersioned(plainTextValue, { - workspaceId, - }) - : plainTextValue; - await this.applicationVariableRepository.update( { key, applicationId }, { - value: encryptedValue, + value: this.secretEncryptionService.encryptVersioned(plainTextValue, { + workspaceId, + }), }, ); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/dtos/update-application-variable.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/dtos/update-application-variable.input.ts index 85f9bf220e..323e29a964 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-variable/dtos/update-application-variable.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/dtos/update-application-variable.input.ts @@ -1,5 +1,6 @@ import { ArgsType, Field } from '@nestjs/graphql'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @ArgsType() @@ -8,7 +9,7 @@ export class UpdateApplicationVariableEntityInput { key: string; @Field(() => String, { nullable: false }) - value: string; + value: PlaintextString; @Field(() => UUIDScalarType, { nullable: false }) applicationId: string; diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index 34e5f6c241..bb2c2dc092 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -247,7 +247,9 @@ export class ConnectionProviderOAuthFlowService { const { encryptedAccessToken, encryptedRefreshToken } = this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, + refreshToken: isDefined(tokenResponse.refreshToken) + ? tokenResponse.refreshToken + : null, workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts index 9fe0b8c247..f847118bc2 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts @@ -4,12 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; -import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; -import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; -import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; -import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; +import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @Injectable() @@ -52,7 +52,7 @@ export class ConnectionProviderService { const valuesByKey = new Map( variables.map((v) => [ v.key, - v.encryptedValue + v.encryptedValue !== '' ? this.secretEncryptionService.decryptVersioned(v.encryptedValue) : '', ]), diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts index 07b7999924..3f997f01b8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts @@ -5,15 +5,16 @@ import { isDefined } from 'twenty-shared/utils'; import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; -import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util'; import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; +import { type ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; @Injectable() export class AppOAuthRefreshAccessTokenService { @@ -26,8 +27,8 @@ export class AppOAuthRefreshAccessTokenService { async refreshTokens( connectedAccount: ConnectedAccountEntity, - refreshToken: string, - ): Promise { + refreshToken: PlaintextString, + ): Promise { if (!isDefined(connectedAccount.connectionProviderId)) { throw new ConnectedAccountRefreshAccessTokenException( `Connected account ${connectedAccount.id} has no connectionProviderId`, @@ -53,7 +54,9 @@ export class AppOAuthRefreshAccessTokenService { accessToken: tokenResponse.accessToken, // Fall back to the original when the response omits one — some // providers don't rotate refresh tokens. - refreshToken: tokenResponse.refreshToken ?? refreshToken, + refreshToken: isDefined(tokenResponse.refreshToken) + ? tokenResponse.refreshToken + : refreshToken, }; } catch (error) { this.logger.warn( diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/types/token-exchange-response.type.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/types/token-exchange-response.type.ts index 9afa70ae50..125af9f710 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/types/token-exchange-response.type.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/types/token-exchange-response.type.ts @@ -1,5 +1,7 @@ +import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings'; + export type TokenExchangeResponse = { - accessToken: string; - refreshToken: string | null; + accessToken: PlaintextString; + refreshToken: PlaintextString | null; scopes: string[] | null; }; diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/parse-token-response.util.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/parse-token-response.util.ts index e3249f7f69..0868b97cc7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/parse-token-response.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/parse-token-response.util.ts @@ -1,10 +1,13 @@ import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; export const parseTokenResponse = ( json: Record, ): TokenExchangeResponse => { const accessToken = - typeof json.access_token === 'string' ? json.access_token : null; + typeof json.access_token === 'string' + ? (json.access_token as PlaintextString) + : null; if (!accessToken) { throw new Error( @@ -15,7 +18,9 @@ export const parseTokenResponse = ( return { accessToken, refreshToken: - typeof json.refresh_token === 'string' ? json.refresh_token : null, + typeof json.refresh_token === 'string' + ? (json.refresh_token as PlaintextString) + : null, scopes: typeof json.scope === 'string' ? json.scope.split(/[\s,]+/).filter(Boolean) diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts index e1673d7f5b..a355942993 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { type ConnectedAccountProvider } from 'twenty-shared/types'; import { EntityManager, Repository } from 'typeorm'; +import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; @@ -18,8 +19,8 @@ export type CreateConnectedAccountInput = { connectedAccountId: string; handle: string; provider: ConnectedAccountProvider; - accessToken: string; - refreshToken: string; + accessToken: PlaintextString; + refreshToken: PlaintextString; accountOwnerId: string; scopes: string[]; transactionManager: EntityManager; @@ -87,6 +88,9 @@ export class CreateConnectedAccountService { const userWorkspaceId = userWorkspace.id; + // Boundary: tokens entering here were just issued by the external + // OAuth provider (Google / Microsoft / app), so we brand them as + // plaintext before handing them to the encryption service. const { encryptedAccessToken, encryptedRefreshToken } = this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts index 999f55a384..80b24702aa 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts @@ -8,6 +8,7 @@ import { MessageChannelVisibility, } from 'twenty-shared/types'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; @@ -278,8 +279,8 @@ describe('GoogleAPIsService', () => { userId: 'user-id', workspaceMemberId: 'workspace-member-id', workspaceId: 'workspace-id', - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', + accessToken: 'new-access-token' as PlaintextString, + refreshToken: 'new-refresh-token' as PlaintextString, calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index 9dcab5fac9..5af45f759b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -16,6 +16,7 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; @@ -83,8 +84,8 @@ export class GoogleAPIsService { userId: string; workspaceMemberId: string; workspaceId: string; - accessToken: string; - refreshToken: string; + accessToken: PlaintextString; + refreshToken: PlaintextString; calendarVisibility: CalendarChannelVisibility | undefined; messageVisibility: MessageChannelVisibility | undefined; skipMessageChannelConfiguration?: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts index e15284189c..345bd1db0f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts @@ -8,6 +8,7 @@ import { MessageChannelVisibility, } from 'twenty-shared/types'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; @@ -257,8 +258,8 @@ describe('MicrosoftAPIsService', () => { userId: 'user-id', workspaceMemberId: 'workspace-member-id', workspaceId: 'workspace-id', - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', + accessToken: 'new-access-token' as PlaintextString, + refreshToken: 'new-refresh-token' as PlaintextString, calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts index eb96e017a0..2acf68516e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts @@ -15,6 +15,7 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; @@ -79,8 +80,8 @@ export class MicrosoftAPIsService { userId: string; workspaceMemberId: string; workspaceId: string; - accessToken: string; - refreshToken: string; + accessToken: PlaintextString; + refreshToken: PlaintextString; calendarVisibility: CalendarChannelVisibility | undefined; messageVisibility: MessageChannelVisibility | undefined; skipMessageChannelConfiguration?: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts index 30b57ec7c0..461c17c524 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { EntityManager } from 'typeorm'; +import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -10,8 +11,8 @@ import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system export type UpdateConnectedAccountOnReconnectInput = { workspaceId: string; connectedAccountId: string; - accessToken: string; - refreshToken: string; + accessToken: PlaintextString; + refreshToken: PlaintextString; scopes: string[]; transactionManager: EntityManager; }; @@ -34,6 +35,9 @@ export class UpdateConnectedAccountOnReconnectService { scopes, } = input; + // Boundary: tokens entering here were just re-issued by the external + // OAuth provider on reconnect, so we brand them as plaintext before + // handing them to the encryption service. const { encryptedAccessToken, encryptedRefreshToken } = this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts index 88b8717e9e..ae0ebd771b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts @@ -8,6 +8,7 @@ import { parseJson } from 'twenty-shared/utils'; import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy'; import { type APIsOAuthRequest } from 'src/engine/core-modules/auth/types/apis-oauth-request.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { type APIsOAuthState } from 'src/engine/core-modules/auth/types/apis-oauth-state.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -19,8 +20,8 @@ export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauth async validate( request: APIsOAuthRequest, - accessToken: string, - refreshToken: string, + accessToken: PlaintextString, + refreshToken: PlaintextString, profile: GoogleProfile, done: VerifyCallback, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts index 89cd51af91..17f1bdafe0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy.ts @@ -5,6 +5,7 @@ import { parseJson } from 'twenty-shared/utils'; import { MicrosoftAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy'; import { type APIsOAuthRequest } from 'src/engine/core-modules/auth/types/apis-oauth-request.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { type APIsOAuthState } from 'src/engine/core-modules/auth/types/apis-oauth-state.type'; import { type MicrosoftPassportProfile } from 'src/engine/core-modules/auth/types/microsoft-passport-profile.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -17,8 +18,8 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenStrategy extends MicrosoftAPI async validate( request: APIsOAuthRequest, - accessToken: string, - refreshToken: string, + accessToken: PlaintextString, + refreshToken: PlaintextString, profile: MicrosoftPassportProfile, done: VerifyCallback, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/apis-oauth-request.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/apis-oauth-request.type.ts index 6e56b0b21b..63ae9149b3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/apis-oauth-request.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/apis-oauth-request.type.ts @@ -5,6 +5,8 @@ import { type MessageChannelVisibility, } from 'twenty-shared/types'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; + export type APIsOAuthRequest = Omit< Request, 'user' | 'workspace' | 'workspaceMetadataVersion' @@ -15,8 +17,8 @@ export type APIsOAuthRequest = Omit< emails: { value: string }[]; picture: string | null; workspaceInviteHash?: string; - accessToken: string; - refreshToken: string; + accessToken: PlaintextString; + refreshToken: PlaintextString; transientToken: string; redirectLocation?: string; calendarVisibility?: CalendarChannelVisibility; diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema.ts index 9120ec8430..319e27b6f5 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema.ts @@ -1,8 +1,7 @@ -import { z } from 'zod'; - import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema'; +import { plaintextStringSchema } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; export const connectionParametersUpdateSchema = connectionParametersSchema.extend({ - password: z.string().min(1, 'Password is required').optional(), + password: plaintextStringSchema.min(1, 'Password is required').optional(), }); diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema.ts index 5e589434e0..f979e7ed6e 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema.ts @@ -1,9 +1,10 @@ +import { plaintextStringSchema } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { z } from 'zod'; export const connectionParametersSchema = z.object({ host: z.string().min(1, 'Host is required'), port: z.int().positive('Port must be a positive number'), username: z.string().optional(), - password: z.string().min(1, 'Password is required'), + password: plaintextStringSchema.min(1, 'Password is required'), secure: z.boolean().optional(), }); diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts index 4a74003e89..14864f7dfd 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts @@ -7,7 +7,7 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er import { ConnectionParametersInput } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.input'; import { connectionParametersUpdateSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema'; import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema'; -import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type PlaintextConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; @Injectable() @@ -21,8 +21,11 @@ export class ImapSmtpCaldavValidatorService { existingProtocolParams, }: { params: ConnectionParametersInput; - existingProtocolParams: ConnectionParameters | null; - }): Promise { + // The caller decrypts the at-rest params before calling us so we work + // exclusively with plaintext passwords (either a new one supplied by + // the user or the previously decrypted existing one). + existingProtocolParams: PlaintextConnectionParameters | null; + }): Promise { if (!params) { throw new UserInputError('Protocol connection parameters are required', { userFriendlyMessage: msg`Please provide connection details to configure your email account.`, @@ -61,8 +64,9 @@ export class ImapSmtpCaldavValidatorService { ); } - const password = - validated.password ?? existingProtocolParams?.password ?? null; + const password = isNonEmptyString(validated.password) + ? validated.password + : (existingProtocolParams?.password ?? null); if (!isNonEmptyString(password)) { throw new UserInputError( diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts index 66ca512a92..03d762913b 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts @@ -12,7 +12,7 @@ import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smt import { type AccountType, type ConnectionParameters, - type ImapSmtpCaldavParams, + type PlaintextImapSmtpCaldavParams, } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -217,9 +217,9 @@ export class ImapSmtpCaldavService { }: { connectionParameters: EmailAccountConnectionParametersInput; handle: string; - existingConnectionParameters: ImapSmtpCaldavParams | null; - }): Promise { - const validatedParams: ImapSmtpCaldavParams = {}; + existingConnectionParameters: PlaintextImapSmtpCaldavParams | null; + }): Promise { + const validatedParams: PlaintextImapSmtpCaldavParams = {}; for (const protocol of ACCOUNT_TYPES) { const params = connectionParameters[protocol]; diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts index 45f016e1f0..7be1c1ce20 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts @@ -3,17 +3,39 @@ import { z } from 'zod'; import { ACCOUNT_TYPES } from 'twenty-shared/constants'; import { connectionParametersUpdateSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema'; import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; -export type ConnectionParameters = z.infer; +// `Pwd` parameterizes the leaf `password` type so the same shape can describe +// the at-rest form (`EncryptedConnectionParameters`) and the in-flight form +// (`PlaintextConnectionParameters`) without losing type safety at the JSONB +// boundary. Defaulting to `string` keeps unbranded consumers — GraphQL DTOs, +// read-only host/port/username consumers — source-compatible. Domain code +// should prefer the named aliases below over re-parameterizing inline. +export type ConnectionParameters = Omit< + z.infer, + 'password' +> & { password: Pwd }; -export type ConnectionParametersUpdate = z.infer< - typeof connectionParametersUpdateSchema ->; +export type ConnectionParametersUpdate = Omit< + z.infer, + 'password' +> & { password?: Pwd }; export type AccountType = (typeof ACCOUNT_TYPES)[number]; -export type ImapSmtpCaldavParams = { - IMAP?: ConnectionParameters; - SMTP?: ConnectionParameters; - CALDAV?: ConnectionParameters; +export type ImapSmtpCaldavParams = { + IMAP?: ConnectionParameters; + SMTP?: ConnectionParameters; + CALDAV?: ConnectionParameters; }; + +export type EncryptedConnectionParameters = + ConnectionParameters; +export type PlaintextConnectionParameters = + ConnectionParameters; + +export type EncryptedImapSmtpCaldavParams = + ImapSmtpCaldavParams; +export type PlaintextImapSmtpCaldavParams = + ImapSmtpCaldavParams; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts b/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts index dedd940927..0c21d169d9 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts @@ -8,6 +8,8 @@ import { UpdateDateColumn, } from 'typeorm'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; + @Entity({ name: 'signingKey', schema: 'core' }) @Index('IDX_SIGNING_KEY_IS_CURRENT_UNIQUE', ['isCurrent'], { unique: true, @@ -27,7 +29,7 @@ export class SigningKeyEntity { publicKey: string; @Column({ type: 'varchar', nullable: true }) - privateKey: string | null; + privateKey: EncryptedString | null; @Column({ type: 'boolean', default: false }) isCurrent: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts index 8698061b94..f08ffe14a9 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts @@ -13,6 +13,8 @@ import { JwtKeyManagerException, JwtKeyManagerExceptionCode, } from 'src/engine/core-modules/jwt/jwt-key-manager.exception'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; export type CurrentSigningKey = { @@ -179,7 +181,7 @@ export class JwtKeyManagerService { } private decryptPrivateKey( - encryptedPrivateKey: string | null, + encryptedPrivateKey: EncryptedString | null, id: string, ): string { if (!isDefined(encryptedPrivateKey)) { @@ -230,7 +232,7 @@ export class JwtKeyManagerService { } private generateEcP256KeyPair(): { - privateKeyPem: string; + privateKeyPem: PlaintextString; publicKeyPem: string; } { const { privateKey, publicKey } = generateKeyPairSync('ec', { @@ -239,7 +241,7 @@ export class JwtKeyManagerService { const privateKeyPem = privateKey .export({ format: 'pem', type: 'pkcs8' }) - .toString(); + .toString() as PlaintextString; const publicKeyPem = publicKey .export({ format: 'pem', type: 'spki' }) .toString(); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index cd80acc724..719f8ff5c4 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -306,9 +306,11 @@ export class LogicFunctionExecutorService { // use the instance-scoped versioned envelope (no workspaceId in the HKDF // info). for (const variable of serverVariables) { - envMap[variable.key] = this.secretEncryptionService.decryptVersioned( - variable.encryptedValue, - ); + if (variable.encryptedValue !== '') { + envMap[variable.key] = this.secretEncryptionService.decryptVersioned( + variable.encryptedValue, + ); + } } return envMap; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts index 53cb63cb8a..c8cf288d27 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts @@ -1,6 +1,7 @@ -import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; -import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var'; +import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; describe('buildEnvVar', () => { const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; @@ -27,12 +28,13 @@ describe('buildEnvVar', () => { expect(result).toEqual({}); }); - it('should decrypt secret variables with the row workspaceId bound to HKDF', () => { + it('should decrypt all encrypted variables regardless of isSecret', () => { const flatVariables: FlatApplicationVariable[] = [ { id: '1', key: 'PUBLIC_URL', - value: 'https://example.com', + value: + `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString, description: 'Public URL', isSecret: false, applicationId: 'app-1', @@ -45,7 +47,7 @@ describe('buildEnvVar', () => { { id: '2', key: 'API_SECRET', - value: `enc:v2:deadbeef:secret-123|${workspaceA}`, + value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString, description: 'API secret', isSecret: true, applicationId: 'app-1', @@ -58,7 +60,7 @@ describe('buildEnvVar', () => { { id: '3', key: 'DEBUG', - value: 'true', + value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString, description: 'Debug flag', isSecret: false, applicationId: 'app-1', @@ -78,11 +80,7 @@ describe('buildEnvVar', () => { DEBUG: 'true', }); expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledTimes( - 1, - ); - expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith( - `enc:v2:deadbeef:secret-123|${workspaceA}`, - { workspaceId: workspaceA }, + 3, ); }); @@ -91,7 +89,7 @@ describe('buildEnvVar', () => { { id: '1', key: 'A_SECRET', - value: `enc:v2:deadbeef:value-a|${workspaceA}`, + value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString, description: '', isSecret: true, applicationId: 'app-1', @@ -104,7 +102,7 @@ describe('buildEnvVar', () => { { id: '2', key: 'B_SECRET', - value: `enc:v2:deadbeef:value-b|${workspaceB}`, + value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString, description: '', isSecret: true, applicationId: 'app-1', @@ -133,7 +131,7 @@ describe('buildEnvVar', () => { { id: '1', key: 'NULL_VALUE', - value: null as unknown as string, + value: null as unknown as EncryptedString | '', description: '', isSecret: false, applicationId: 'app-1', @@ -146,7 +144,7 @@ describe('buildEnvVar', () => { { id: '2', key: 'UNDEFINED_VALUE', - value: undefined as unknown as string, + value: undefined as unknown as EncryptedString | '', description: '', isSecret: false, applicationId: 'app-1', @@ -171,7 +169,7 @@ describe('buildEnvVar', () => { { id: '1', key: 'NUMBER_VALUE', - value: 123 as unknown as string, + value: 123 as unknown as EncryptedString | '', description: '', isSecret: false, applicationId: 'app-1', diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts index 3360441d05..bf82c85a89 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts @@ -1,7 +1,9 @@ -import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; -import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { isNonEmptyString } from '@sniptt/guards'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; + export const buildEnvVar = ( flatApplicationVariables: FlatApplicationVariable[], secretEncryptionService: SecretEncryptionService, @@ -10,8 +12,10 @@ export const buildEnvVar = ( (acc, flatApplicationVariable) => { const value = String(flatApplicationVariable.value ?? ''); + // TODO: After 2-9 slow instance command has run everywhere, turn + // the else branch into an invariant violation for non-empty values. acc[flatApplicationVariable.key] = - flatApplicationVariable.isSecret && isNonEmptyString(value) + isNonEmptyString(value) && isEncryptedString(value) ? secretEncryptionService.decryptVersioned(value, { workspaceId: flatApplicationVariable.workspaceId, }) diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/brands.type-test.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/brands.type-test.ts new file mode 100644 index 0000000000..46023d3086 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/brands.type-test.ts @@ -0,0 +1,51 @@ +import { type Equal, type Expect } from 'twenty-shared/testing'; + +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; + +// This file contains compile-time-only assertions: if the brand contract +// is violated, `tsc` fails. There are no runtime expectations. The unused +// type aliases below intentionally exist solely to surface `Expect<...>` +// failures. + +// Raw `string` is NOT assignable to either brand (the structural anchor +// of the hard brand). A failure here means the brand became soft. +type RawStringIsNotAssignableToEncrypted = Expect< + Equal +>; + +type RawStringIsNotAssignableToPlaintext = Expect< + Equal +>; + +// Both brands erase to `string` for read-only consumers (logging, DB +// writes, GraphQL responses). A failure here means the brand became a +// disjoint type and would force unnecessary coercion across the codebase. +type EncryptedErasesToString = Expect< + Equal +>; + +type PlaintextErasesToString = Expect< + Equal +>; + +// The two brands are not interchangeable: passing ciphertext where +// plaintext is expected (or vice versa) must be a type error. This is +// the core invariant guarding against the #20819 class of bug. +type EncryptedIsNotAssignableToPlaintext = Expect< + Equal +>; + +type PlaintextIsNotAssignableToEncrypted = Expect< + Equal +>; + +// oxlint-disable-next-line unused-imports/no-unused-vars +type BrandInvariants = [ + RawStringIsNotAssignableToEncrypted, + RawStringIsNotAssignableToPlaintext, + EncryptedErasesToString, + PlaintextErasesToString, + EncryptedIsNotAssignableToPlaintext, + PlaintextIsNotAssignableToEncrypted, +]; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/is-encrypted-string.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/is-encrypted-string.util.spec.ts new file mode 100644 index 0000000000..64cf4ec9a2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/__tests__/is-encrypted-string.util.spec.ts @@ -0,0 +1,27 @@ +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; + +describe('isEncryptedString', () => { + it('returns true for a v2-enveloped value', () => { + expect(isEncryptedString('enc:v2:0123abcd:cGF5bG9hZA==')).toBe(true); + }); + + it('returns true for any value carrying the enc: prefix (future-proof)', () => { + expect(isEncryptedString('enc:v3:future-shape')).toBe(true); + }); + + it('returns false for plaintext input', () => { + expect(isEncryptedString('my-plaintext-token')).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(isEncryptedString('')).toBe(false); + }); + + it('returns false for legacy unprefixed CTR ciphertext (raw base64)', () => { + // Legacy CTR ciphertext has no envelope prefix and is structurally + // indistinguishable from arbitrary base64 input. The predicate + // intentionally returns false for these so callers fall through to + // the legacy unbranded decrypt path. + expect(isEncryptedString('aGVsbG8gd29ybGQgaW4gYmFzZTY0')).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type.ts new file mode 100644 index 0000000000..a93bc3c53e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +const encryptedStringSchema = z.string().brand('ENCRYPTED_STRING_BRAND'); + +export type EncryptedString = z.infer; 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 new file mode 100644 index 0000000000..eb2d5b4b64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/index.ts @@ -0,0 +1,3 @@ +export { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.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/core-modules/secret-encryption/branded-strings/is-encrypted-string.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util.ts new file mode 100644 index 0000000000..62ce65dbe9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util.ts @@ -0,0 +1,5 @@ +import { SECRET_ENCRYPTION_ENVELOPE_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; + +export const isEncryptedString = (value: string): value is EncryptedString => + value.startsWith(SECRET_ENCRYPTION_ENVELOPE_PREFIX); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type.ts new file mode 100644 index 0000000000..33eb927303 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const plaintextStringSchema = z.string().brand('PLAINTEXT_STRING_BRAND'); + +export type PlaintextString = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.spec.ts index f7ae6ec803..b8031e351d 100644 --- a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.spec.ts @@ -1,5 +1,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; import { SecretEncryptionService } from './secret-encryption.service'; @@ -194,7 +196,7 @@ describe('SecretEncryptionService', () => { it('round-trips a v2 envelope and applies the mask', () => { const secret = 'sk-abcdefghij1234567890'; - const encrypted = service.encryptVersioned(secret); + const encrypted = service.encryptVersioned(secret as PlaintextString); const result = service.decryptAndMaskVersioned({ value: encrypted, @@ -208,7 +210,9 @@ describe('SecretEncryptionService', () => { it('decrypts a workspace-scoped v2 envelope when given the matching workspaceId', () => { const workspaceId = '11111111-1111-1111-1111-111111111111'; const secret = 'sk-workspace-bound-secret'; - const encrypted = service.encryptVersioned(secret, { workspaceId }); + const encrypted = service.encryptVersioned(secret as PlaintextString, { + workspaceId, + }); const result = service.decryptAndMaskVersioned({ value: encrypted, @@ -223,13 +227,13 @@ describe('SecretEncryptionService', () => { it('returns null/undefined values as-is', () => { expect( service.decryptAndMaskVersioned({ - value: null as unknown as string, + value: null as unknown as EncryptedString, mask, }), ).toBeNull(); expect( service.decryptAndMaskVersioned({ - value: undefined as unknown as string, + value: undefined as unknown as EncryptedString, mask, }), ).toBeUndefined(); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts index ca5555e995..ff89ff7693 100644 --- a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; import { computeEncryptionKeyId } from './utils/compute-encryption-key-id.util'; @@ -27,6 +29,9 @@ export class SecretEncryptionService { private readonly environmentConfigDriver: EnvironmentConfigDriver, ) {} + // Legacy CTR pair (`encrypt` / `decrypt`) is intentionally left unbranded. + // Its callers predate the enc:v2 envelope and never went through the + // branded API; retrofitting them is tracked as a separate follow-up. public encrypt(value: string): string { if (!isDefined(value)) { return value; @@ -73,7 +78,7 @@ export class SecretEncryptionService { mask, workspaceId, }: { - value: string; + value: EncryptedString; mask: string; workspaceId?: string; }): string { @@ -98,7 +103,10 @@ export class SecretEncryptionService { return `${decryptedValue.slice(0, visibleCharsCount)}${mask}`; } - public encryptVersioned(value: string, opts: VersionedOptions = {}): string { + public encryptVersioned( + value: PlaintextString, + opts: VersionedOptions = {}, + ): EncryptedString { if (!isDefined(value)) { return value; } @@ -113,10 +121,16 @@ export class SecretEncryptionService { }); const keyId = computeEncryptionKeyId({ rawKey: primary }); - return formatSecretEncryptionEnvelopeV2({ keyId, payloadBase64 }); + return formatSecretEncryptionEnvelopeV2({ + keyId, + payloadBase64, + }) as EncryptedString; } - public decryptVersioned(value: string, opts: VersionedOptions = {}): string { + public decryptVersioned( + value: EncryptedString, + opts: VersionedOptions = {}, + ): PlaintextString { if (!isDefined(value)) { return value; } @@ -136,12 +150,12 @@ export class SecretEncryptionService { payloadBase64: parsed.payload, rawKey, workspaceId: opts.workspaceId, - }); + }) as PlaintextString; } this.warnLegacyCtrDecryptionOnce(); - return this.decrypt(value); + return this.decrypt(value) as PlaintextString; } private warnLegacyCtrDecryptionOnce(): void { diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/__tests__/config-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/__tests__/config-storage.service.spec.ts index 9ede337da1..c03da3e54e 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/__tests__/config-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/__tests__/config-storage.service.spec.ts @@ -173,8 +173,7 @@ describe('ConfigStorageService', () => { it('should decrypt sensitive string values', async () => { const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables; - const originalValue = 'sensitive-value'; - const encryptedValue = 'sensitive-value'; + const encryptedValue = 'enc:v2:deadbeef:sensitive-value'; const mockRecord = createMockKeyValuePair(key as string, encryptedValue); @@ -197,7 +196,7 @@ describe('ConfigStorageService', () => { const result = await service.get(key); - expect(result).toBe(originalValue); + expect(result).toBe(encryptedValue); expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith( encryptedValue, ); @@ -538,7 +537,10 @@ describe('ConfigStorageService', () => { it('should decrypt sensitive string values in loadAll', async () => { const configVars: KeyValuePairEntity[] = [ - createMockKeyValuePair('SENSITIVE_CONFIG', 'sensitive-value'), + createMockKeyValuePair( + 'SENSITIVE_CONFIG', + 'enc:v2:deadbeef:sensitive-value', + ), createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'), ]; @@ -565,13 +567,13 @@ describe('ConfigStorageService', () => { expect(result.size).toBe(2); expect(result.get('SENSITIVE_CONFIG' as keyof ConfigVariables)).toBe( - 'sensitive-value', + 'enc:v2:deadbeef:sensitive-value', ); expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe( 'normal-value', ); expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith( - 'sensitive-value', + 'enc:v2:deadbeef:sensitive-value', ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts index 6522021bf7..7df6d4ca47 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts @@ -7,6 +7,8 @@ import { KeyValuePairEntity, KeyValuePairType, } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; @@ -47,31 +49,64 @@ export class ConfigStorageService implements ConfigStorageInterface { ]; } - private async convertAndSecureValue( - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - value: any, + private isSensitiveStringValue( + value: unknown, + key: keyof ConfigVariables, + ): value is string { + const metadata = this.getConfigMetadata(key); + + return ( + typeof value === 'string' && + metadata?.isSensitive === true && + metadata.type === ConfigVariableType.STRING + ); + } + + private async convertAndDecrypt( + dbValue: unknown, key: T, - isDecrypt = false, - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - ): Promise { + ): Promise { try { - const convertedValue = isDecrypt - ? this.configValueConverter.convertDbValueToAppValue(value, key) - : this.configValueConverter.convertAppValueToDbValue(value, key); + const convertedValue = this.configValueConverter.convertDbValueToAppValue( + dbValue, + key, + ); - const metadata = this.getConfigMetadata(key); - const isSensitiveString = - metadata?.isSensitive && - metadata.type === ConfigVariableType.STRING && - typeof convertedValue === 'string'; - - if (!isSensitiveString) { - return convertedValue; + if ( + this.isSensitiveStringValue(convertedValue, key) && + isEncryptedString(convertedValue) + ) { + return this.secretEncryptionService.decryptVersioned( + convertedValue, + ) as unknown as ConfigVariables[T]; } - return isDecrypt - ? this.secretEncryptionService.decryptVersioned(convertedValue) - : this.secretEncryptionService.encryptVersioned(convertedValue); + return convertedValue; + } catch (error) { + throw new ConfigVariableException( + `Failed to convert value for key ${key as string}: ${error.message}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + } + + private async convertAndEncrypt( + appValue: ConfigVariables[T], + key: T, + ): Promise { + try { + const convertedValue = this.configValueConverter.convertAppValueToDbValue( + appValue, + key, + ); + + if (this.isSensitiveStringValue(convertedValue, key)) { + return this.secretEncryptionService.encryptVersioned( + convertedValue as PlaintextString, + ) as unknown as KeyValuePairEntity['value']; + } + + return convertedValue as KeyValuePairEntity['value']; } catch (error) { throw new ConfigVariableException( `Failed to convert value for key ${key as string}: ${error.message}`, @@ -96,7 +131,7 @@ export class ConfigStorageService implements ConfigStorageInterface { `Fetching config for ${key as string} in database: ${result?.value}`, ); - return await this.convertAndSecureValue(result.value, key, true); + return await this.convertAndDecrypt(result.value, key); } catch (error) { if (error instanceof ConfigVariableException) { throw error; @@ -114,7 +149,7 @@ export class ConfigStorageService implements ConfigStorageInterface { value: ConfigVariables[T], ): Promise { try { - const dbValue = await this.convertAndSecureValue(value, key, false); + const dbValue = await this.convertAndEncrypt(value, key); const existingRecord = await this.keyValuePairRepository.findOne({ where: this.getConfigVariableWhereClause(key as string), @@ -177,11 +212,7 @@ export class ConfigStorageService implements ConfigStorageInterface { const key = configVar.key as keyof ConfigVariables; try { - const value = await this.convertAndSecureValue( - configVar.value, - key, - true, - ); + const value = await this.convertAndDecrypt(configVar.value, key); if (value !== undefined) { result.set(key, value); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity.ts index cb5b35febe..f43f086722 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity.ts @@ -12,6 +12,8 @@ import { UpdateDateColumn, } from 'typeorm'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; + import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -48,7 +50,7 @@ export class TwoFactorAuthenticationMethodEntity { userWorkspace: Relation; @Column({ nullable: false, type: 'text' }) - secret: string; + secret: EncryptedString; @Column({ type: 'enum', diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants.ts index a0d74ed20a..03db3853df 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { type OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants'; export enum TOTPHashAlgorithms { @@ -26,7 +27,7 @@ export const TOTP_DEFAULT_CONFIGURATION = { export type TotpContext = { status: OTPStatus; - secret: string; + secret: PlaintextString; }; export type TOTPStrategyConfig = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.spec.ts index 8ce9035469..60ec132fbb 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.spec.ts @@ -1,5 +1,6 @@ import { authenticator } from 'otplib'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants'; import { TotpStrategy } from './totp.strategy'; @@ -97,7 +98,7 @@ describe('TOTPStrategy Configuration', () => { context = { status: OTPStatus.VERIFIED, - secret, + secret: secret as PlaintextString, }; }); @@ -143,7 +144,7 @@ describe('TOTPStrategy Configuration', () => { it('should handle invalid secret gracefully', () => { const invalidContext = { status: OTPStatus.VERIFIED, - secret: 'invalid-secret', + secret: 'invalid-secret' as PlaintextString, }; // The authenticator.check method doesn't throw for invalid secrets, @@ -156,7 +157,7 @@ describe('TOTPStrategy Configuration', () => { it('should handle empty secret gracefully', () => { const invalidContext = { status: OTPStatus.VERIFIED, - secret: '', + secret: '' as PlaintextString, }; // The authenticator.check method doesn't throw for empty secrets, @@ -195,7 +196,7 @@ describe('TOTPStrategy Configuration', () => { it('should handle empty token gracefully', () => { const context = { status: OTPStatus.VERIFIED, - secret, + secret: secret as PlaintextString, }; const result = strategy.validate('', context); @@ -207,7 +208,7 @@ describe('TOTPStrategy Configuration', () => { it('should handle null token gracefully', () => { const context = { status: OTPStatus.VERIFIED, - secret, + secret: secret as PlaintextString, }; const result = strategy.validate(null as any, context); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.ts index d21fa98cb6..f360b71d8b 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.ts @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { type ZodSafeParseResult } from 'zod'; import { type OTPAuthenticationStrategyInterface } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/interfaces/otp.strategy.interface'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants'; import { @@ -54,7 +55,7 @@ export class TotpStrategy implements OTPAuthenticationStrategyInterface { uri: string; context: TotpContext; } { - const secret = authenticator.generateSecret(); + const secret = authenticator.generateSecret() as PlaintextString; const uri = authenticator.keyuri(accountName, issuer, secret); return { diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts index 5db5708216..80e56df3d8 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts @@ -8,6 +8,8 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -55,10 +57,10 @@ export class TwoFactorAuthenticationService { userId, workspaceId, }: { - storedSecret: string; + storedSecret: EncryptedString; userId: string; workspaceId: string; - }): Promise { + }): Promise { if (storedSecret.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)) { return this.secretEncryptionService.decryptVersioned(storedSecret, { workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts index 1d892681e3..8c9a8236ef 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts @@ -4,6 +4,7 @@ import { createDecipheriv, createHash } from 'crypto'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; // TODO: delete this util once the 2.5 cross-upgrade window closes and every // `core.twoFactorAuthenticationMethod.secret` row is known to be in the @@ -26,7 +27,7 @@ export class SimpleSecretEncryptionUtil { async decryptSecret( encryptedSecret: string, purpose: string, - ): Promise { + ): Promise { const appSecret = this.jwtWrapperService.generateAppSecret( JwtTokenTypeEnum.KEY_ENCRYPTION_KEY, purpose, @@ -45,6 +46,6 @@ export class SimpleSecretEncryptionUtil { decrypted += decipher.final('utf8'); - return decrypted; + return decrypted as PlaintextString; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts index 244da0c97e..1db836661d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts @@ -16,7 +16,8 @@ import { type ConnectedAccountProvider } from 'twenty-shared/types'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type EncryptedImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; @@ -53,10 +54,10 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity { provider: ConnectedAccountProvider; @Column({ type: 'varchar', nullable: true }) - accessToken: string | null; + accessToken: EncryptedString | null; @Column({ type: 'varchar', nullable: true }) - refreshToken: string | null; + refreshToken: EncryptedString | null; @Column({ type: 'timestamptz', nullable: true }) lastCredentialsRefreshedAt: Date | null; @@ -71,7 +72,7 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity { scopes: string[] | null; @Column({ type: 'jsonb', nullable: true }) - connectionParameters: ImapSmtpCaldavParams | null; + connectionParameters: EncryptedImapSmtpCaldavParams | null; @Column({ type: 'timestamptz', nullable: true }) lastSignedInAt: Date | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts index 95d412a8fe..4837f5fa77 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts @@ -3,9 +3,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { - type ConnectionParameters, - type ImapSmtpCaldavParams, + type EncryptedConnectionParameters, + type EncryptedImapSmtpCaldavParams, + type PlaintextConnectionParameters, + type PlaintextImapSmtpCaldavParams, } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionException, @@ -28,9 +32,9 @@ export class ConnectedAccountTokenEncryptionService { plaintext, workspaceId, }: { - plaintext: string; + plaintext: PlaintextString; workspaceId: string; - }): string { + }): EncryptedString { if (this.looksLikeCiphertext(plaintext)) { throw new SecretEncryptionException( 'ConnectedAccountTokenEncryptionService.encrypt received an already-encrypted envelope. This indicates a double-encryption bug — the caller is encrypting ciphertext.', @@ -47,9 +51,9 @@ export class ConnectedAccountTokenEncryptionService { plaintext, workspaceId, }: { - plaintext: string | null; + plaintext: PlaintextString | null; workspaceId: string; - }): string | null { + }): EncryptedString | null { if (!isDefined(plaintext)) { return null; } @@ -61,9 +65,9 @@ export class ConnectedAccountTokenEncryptionService { ciphertext, workspaceId, }: { - ciphertext: string; + ciphertext: EncryptedString; workspaceId: string; - }): string { + }): PlaintextString { if (!ciphertext.startsWith(SECRET_ENCRYPTION_ENVELOPE_PREFIX)) { throw new SecretEncryptionException( 'Received a plaintext value where ciphertext was expected. The encryption backfill migration may not have run.', @@ -80,9 +84,9 @@ export class ConnectedAccountTokenEncryptionService { ciphertext, workspaceId, }: { - ciphertext: string | null; + ciphertext: EncryptedString | null; workspaceId: string; - }): string | null { + }): PlaintextString | null { if (!isDefined(ciphertext)) { return null; } @@ -95,12 +99,12 @@ export class ConnectedAccountTokenEncryptionService { refreshToken, workspaceId, }: { - accessToken: string; - refreshToken: string | null; + accessToken: PlaintextString; + refreshToken: PlaintextString | null; workspaceId: string; }): { - encryptedAccessToken: string; - encryptedRefreshToken: string | null; + encryptedAccessToken: EncryptedString; + encryptedRefreshToken: EncryptedString | null; } { return { encryptedAccessToken: this.encrypt({ @@ -122,10 +126,10 @@ export class ConnectedAccountTokenEncryptionService { connectionParameters, workspaceId, }: { - connectionParameters: ImapSmtpCaldavParams; + connectionParameters: PlaintextImapSmtpCaldavParams; workspaceId: string; - }): ImapSmtpCaldavParams { - const result: ImapSmtpCaldavParams = {}; + }): EncryptedImapSmtpCaldavParams { + const result: EncryptedImapSmtpCaldavParams = {}; for (const protocol of ACCOUNT_TYPES) { const params = connectionParameters[protocol]; @@ -147,10 +151,10 @@ export class ConnectedAccountTokenEncryptionService { connectionParameters, workspaceId, }: { - connectionParameters: ImapSmtpCaldavParams; + connectionParameters: EncryptedImapSmtpCaldavParams; workspaceId: string; - }): ImapSmtpCaldavParams { - const result: ImapSmtpCaldavParams = {}; + }): PlaintextImapSmtpCaldavParams { + const result: PlaintextImapSmtpCaldavParams = {}; for (const protocol of ACCOUNT_TYPES) { const params = connectionParameters[protocol]; @@ -172,20 +176,31 @@ export class ConnectedAccountTokenEncryptionService { protocolParams, workspaceId, }: { - protocolParams: ConnectionParameters; + protocolParams: EncryptedConnectionParameters; workspaceId: string; - }): ConnectionParameters { + }): PlaintextConnectionParameters { const isEncrypted = protocolParams.password.startsWith( SECRET_ENCRYPTION_ENVELOPE_PREFIX, ); - // TODO: Remove after 2-5 slow instance command has been run everywhere + // TODO: Remove in follow-up PR once all legacy encryption fallbacks are dropped. + // TODO: Remove after 2-5 slow instance command has been run everywhere. + // During the rollout window protocolParams.password may be a legacy + // unencrypted plaintext value living in the same column. We trust the + // entity-level brand at the type layer (column is EncryptedString) but + // still re-validate at runtime to handle the un-backfilled tail; the + // assert above splits the two. if (!isEncrypted) { this.logger.warn( 'Protocol password is not encrypted. Expected during the rollout window until the slow instance command finishes backfilling.', ); - return protocolParams; + const rawPassword: string = protocolParams.password; + + return { + ...protocolParams, + password: rawPassword as PlaintextString, + }; } return { diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts index 6b709d633a..c73de7c6e2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts @@ -1,3 +1,4 @@ +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables'; @@ -6,7 +7,7 @@ const makeFlatVariable = ( ): FlatApplicationVariable => ({ id: '1', key: 'KEY', - value: 'value', + value: 'value' as EncryptedString, description: '', isSecret: false, applicationId: 'app-1', @@ -25,8 +26,15 @@ describe('stripSecretFromApplicationVariables', () => { it('should include non-secret variables', () => { const variables = [ - makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }), - makeFlatVariable({ id: '2', key: 'DEBUG', value: 'true' }), + makeFlatVariable({ + key: 'PUBLIC_URL', + value: 'https://example.com' as EncryptedString, + }), + makeFlatVariable({ + id: '2', + key: 'DEBUG', + value: 'true' as EncryptedString, + }), ]; expect(stripSecretFromApplicationVariables(variables)).toEqual({ @@ -37,14 +45,21 @@ describe('stripSecretFromApplicationVariables', () => { it('should exclude secret variables', () => { const variables = [ - makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }), + makeFlatVariable({ + key: 'PUBLIC_URL', + value: 'https://example.com' as EncryptedString, + }), makeFlatVariable({ id: '2', key: 'API_SECRET', - value: 'encrypted_secret', + value: 'encrypted_secret' as EncryptedString, isSecret: true, }), - makeFlatVariable({ id: '3', key: 'DEBUG', value: 'true' }), + makeFlatVariable({ + id: '3', + key: 'DEBUG', + value: 'true' as EncryptedString, + }), ]; const result = stripSecretFromApplicationVariables(variables); @@ -60,12 +75,12 @@ describe('stripSecretFromApplicationVariables', () => { const variables = [ makeFlatVariable({ key: 'NULL_VALUE', - value: null as unknown as string, + value: null as unknown as EncryptedString | '', }), makeFlatVariable({ id: '2', key: 'UNDEFINED_VALUE', - value: undefined as unknown as string, + value: undefined as unknown as EncryptedString | '', }), ]; @@ -79,7 +94,7 @@ describe('stripSecretFromApplicationVariables', () => { const variables = [ makeFlatVariable({ key: 'NUMBER_VALUE', - value: 123 as unknown as string, + value: 123 as unknown as EncryptedString | '', }), ]; @@ -90,11 +105,15 @@ describe('stripSecretFromApplicationVariables', () => { it('should return empty object when all variables are secret', () => { const variables = [ - makeFlatVariable({ key: 'SECRET_1', value: 'val1', isSecret: true }), + makeFlatVariable({ + key: 'SECRET_1', + value: 'val1' as EncryptedString, + isSecret: true, + }), makeFlatVariable({ id: '2', key: 'SECRET_2', - value: 'val2', + value: 'val2' as EncryptedString, isSecret: true, }), ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/application-variable/services/update-application-variable-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/application-variable/services/update-application-variable-action-handler.service.ts index 394d4b471b..1e6bd62e68 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/application-variable/services/update-application-variable-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/application-variable/services/update-application-variable-action-handler.service.ts @@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; -import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util'; import { @@ -20,9 +19,7 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr 'update', 'applicationVariable', ) { - constructor( - private readonly secretEncryptionService: SecretEncryptionService, - ) { + constructor() { super(); } @@ -50,6 +47,8 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr }; } + // Value is always encrypted regardless of isSecret, so toggling + // isSecret does not require re-encrypting or decrypting the stored value. async executeForMetadata( context: WorkspaceMigrationActionRunnerContext, ): Promise { @@ -60,34 +59,6 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr ApplicationVariableEntity, ); - const existing = await applicationVariableRepository.findOne({ - where: { id: entityId, workspaceId }, - }); - - if ( - update.isSecret !== undefined && - update.isSecret && - existing && - !existing.isSecret - ) { - (update as Record).value = - this.secretEncryptionService.encryptVersioned(existing.value, { - workspaceId, - }); - } - - if ( - update.isSecret !== undefined && - !update.isSecret && - existing && - existing.isSecret - ) { - (update as Record).value = - this.secretEncryptionService.decryptVersioned(existing.value, { - workspaceId, - }); - } - await applicationVariableRepository.update( { id: entityId, workspaceId }, update, diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts index 6f28cd8d68..997aff47ea 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts @@ -3,19 +3,22 @@ import { Injectable } from '@nestjs/common'; import { google } from 'googleapis'; import { isDefined } from 'twenty-shared/utils'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; -import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; import { parseGoogleOAuthError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util'; +import { type ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; @Injectable() export class GoogleAPIRefreshAccessTokenService { constructor(private readonly twentyConfigService: TwentyConfigService) {} - async refreshTokens(refreshToken: string): Promise { + async refreshTokens( + refreshToken: PlaintextString, + ): Promise { const oAuth2Client = new google.auth.OAuth2( this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'), this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'), @@ -35,7 +38,7 @@ export class GoogleAPIRefreshAccessTokenService { } return { - accessToken: token, + accessToken: token as PlaintextString, refreshToken, }; } catch (error) { diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts index fc30e1380b..a1d0ca5876 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts @@ -2,19 +2,22 @@ import { Injectable } from '@nestjs/common'; import { ConfidentialClientApplication } from '@azure/msal-node'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; -import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util'; +import type { ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; @Injectable() export class MicrosoftAPIRefreshAccessTokenService { constructor(private readonly config: TwentyConfigService) {} - async refreshTokens(refreshToken: string): Promise { + async refreshTokens( + refreshToken: PlaintextString, + ): Promise { const msalClient = new ConfidentialClientApplication({ auth: { clientId: this.config.get('AUTH_MICROSOFT_CLIENT_ID'), @@ -38,7 +41,7 @@ export class MicrosoftAPIRefreshAccessTokenService { } return { - accessToken: response.accessToken, + accessToken: response.accessToken as PlaintextString, refreshToken: this.extractRefreshTokenFromCache(msalClient), }; } catch (error) { @@ -52,7 +55,7 @@ export class MicrosoftAPIRefreshAccessTokenService { private extractRefreshTokenFromCache( msalClient: ConfidentialClientApplication, - ): string { + ): PlaintextString { const tokenCache = JSON.parse(msalClient.getTokenCache().serialize()); const refreshTokenKey = Object.keys(tokenCache.RefreshToken)[0]; diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts index 74ebf85700..9dd9e92c2e 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts @@ -15,6 +15,7 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; +import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings'; import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service'; const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`; @@ -32,9 +33,9 @@ describe('ConnectedAccountRefreshTokensService', () => { const mockWorkspaceId = 'workspace-123'; const mockConnectedAccountId = 'account-456'; - const mockAccessTokenPlaintext = 'valid-access-token'; - const mockRefreshTokenPlaintext = 'valid-refresh-token'; - const mockNewAccessTokenPlaintext = 'new-access-token'; + const mockAccessTokenPlaintext = 'valid-access-token' as PlaintextString; + const mockRefreshTokenPlaintext = 'valid-refresh-token' as PlaintextString; + const mockNewAccessTokenPlaintext = 'new-access-token' as PlaintextString; const mockEncryptedAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockAccessTokenPlaintext})`; const mockEncryptedRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`; @@ -263,6 +264,7 @@ describe('ConnectedAccountRefreshTokensService', () => { const newPlaintextTokens = { accessToken: mockNewAccessTokenPlaintext, + refreshToken: mockRefreshTokenPlaintext, }; diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts index 99c463ce87..e04c9f5fdd 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts @@ -6,6 +6,8 @@ import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ConnectedAccountRefreshAccessTokenException, @@ -15,11 +17,24 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; -export type ConnectedAccountTokens = { - accessToken: string; - refreshToken: string | null; +// Tokens flowing through this service can be in two states depending on +// where they enter the pipeline. We model both shapes explicitly so the +// type system can prevent the #20819 class of bug (mixing encrypted and +// decrypted tokens in the same flow). +export type ConnectedAccountPlaintextTokens = { + accessToken: PlaintextString; + refreshToken: PlaintextString | null; }; +export type ConnectedAccountEncryptedTokens = { + accessToken: EncryptedString; + refreshToken: EncryptedString | null; +}; + +// Public return type of resolveTokens: always encrypted (either fresh from +// the database or freshly re-encrypted after a refresh round-trip). +export type ConnectedAccountTokens = ConnectedAccountEncryptedTokens; + const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60; @Injectable() @@ -91,7 +106,7 @@ export class ConnectedAccountRefreshTokensService { private async performRefreshAndSave( connectedAccount: ConnectedAccountEntity, - encryptedRefreshToken: string, + encryptedRefreshToken: EncryptedString, workspaceId: string, ): Promise { const decryptedRefreshToken = @@ -166,9 +181,9 @@ export class ConnectedAccountRefreshTokensService { async refreshTokens( connectedAccount: ConnectedAccountEntity, - refreshToken: string, + refreshToken: PlaintextString, workspaceId: string, - ): Promise { + ): Promise { try { switch (connectedAccount.provider) { case ConnectedAccountProvider.GOOGLE: diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts index d27e5c60ee..d61e333474 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts @@ -9,7 +9,12 @@ import { import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { + type EncryptedImapSmtpCaldavParams, + type PlaintextImapSmtpCaldavParams, +} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -117,12 +122,13 @@ describe('ImapSmtpCalDavAPIService', () => { resetAndMarkAsCalendarEventListFetchPending: jest.fn(), }; - const encryptPassword = (password: string) => `enc:v2:${password}`; + const encryptPassword = (password: string): EncryptedString => + `enc:v2:${password}` as EncryptedString; const withEncryptedPasswords = ( - params: ImapSmtpCaldavParams, - ): ImapSmtpCaldavParams => { - const result: ImapSmtpCaldavParams = {}; + params: PlaintextImapSmtpCaldavParams, + ): EncryptedImapSmtpCaldavParams => { + const result: EncryptedImapSmtpCaldavParams = {}; for (const protocol of ['IMAP', 'SMTP', 'CALDAV'] as const) { if (params[protocol]) { @@ -141,7 +147,7 @@ describe('ImapSmtpCalDavAPIService', () => { ({ connectionParameters, }: { - connectionParameters: ImapSmtpCaldavParams; + connectionParameters: PlaintextImapSmtpCaldavParams; workspaceId: string; }) => withEncryptedPasswords(connectionParameters), ), @@ -240,16 +246,16 @@ describe('ImapSmtpCalDavAPIService', () => { host: 'imap.example.com', port: 993, secure: true, - password: 'password', + password: 'password' as PlaintextString, }, SMTP: { host: 'smtp.example.com', port: 587, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; it('should create new account with message channel when account does not exist and IMAP is configured', async () => { @@ -338,9 +344,9 @@ describe('ImapSmtpCalDavAPIService', () => { port: 443, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, connectedAccountId: 'existing-account-id', }; @@ -467,9 +473,9 @@ describe('ImapSmtpCalDavAPIService', () => { host: 'imap.example.com', port: 993, secure: true, - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; mockConnectedAccountRepository.findOne.mockResolvedValue(null); @@ -503,9 +509,9 @@ describe('ImapSmtpCalDavAPIService', () => { port: 443, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; mockConnectedAccountRepository.findOne.mockResolvedValue(null); @@ -538,16 +544,16 @@ describe('ImapSmtpCalDavAPIService', () => { host: 'imap.example.com', port: 993, secure: true, - password: 'password', + password: 'password' as PlaintextString, }, SMTP: { host: 'smtp.example.com', port: 587, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; mockConnectedAccountRepository.findOne.mockResolvedValue(null); @@ -580,23 +586,23 @@ describe('ImapSmtpCalDavAPIService', () => { host: 'imap.example.com', port: 993, secure: true, - password: 'password', + password: 'password' as PlaintextString, }, SMTP: { host: 'smtp.example.com', port: 587, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, CALDAV: { host: 'caldav.example.com', port: 443, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; mockConnectedAccountRepository.findOne.mockResolvedValue(null); @@ -676,9 +682,9 @@ describe('ImapSmtpCalDavAPIService', () => { port: 587, secure: true, username: 'test@example.com', - password: 'password', + password: 'password' as PlaintextString, }, - } as ImapSmtpCaldavParams, + } as PlaintextImapSmtpCaldavParams, }; mockConnectedAccountRepository.findOne.mockResolvedValue(null); diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts index 54f4c36ae8..a5a2362990 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts @@ -13,7 +13,7 @@ import { v4 } from 'uuid'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type PlaintextImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -65,7 +65,10 @@ export class ImapSmtpCalDavAPIService { handle: string; userWorkspaceId: string; workspaceId: string; - connectionParameters: ImapSmtpCaldavParams; + // Caller (resolver) has already validated the input through + // `ImapSmtpCaldavService.validateAndTestConnectionParameters`, which + // produces fully plaintext passwords ready for re-encryption. + connectionParameters: PlaintextImapSmtpCaldavParams; existingAccount?: ConnectedAccountEntity | null; }): Promise { const { handle, workspaceId, userWorkspaceId } = input; diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts index d8fc41318f..6536561d99 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts @@ -13,6 +13,7 @@ import { In } from 'typeorm'; import { type DiscoveredMessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service'; @@ -46,8 +47,8 @@ const createMockMessageChannel = ( id: 'account-456', handle: 'test@gmail.com', provider: overrides.provider ?? ConnectedAccountProvider.GOOGLE, - accessToken: 'mock-access-token', - refreshToken: 'mock-refresh-token', + accessToken: 'mock-access-token' as EncryptedString, + refreshToken: 'mock-refresh-token' as EncryptedString, connectionParameters: {}, workspaceId: 'workspace-123', }, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts index cc0cbcd002..bea5984745 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts @@ -8,6 +8,7 @@ import { import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider'; import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service'; @@ -42,8 +43,8 @@ describe('ImapGetMessageListService', () => { > = { id: 'connected-account-id', provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, - accessToken: 'access-token', - refreshToken: 'refresh-token', + accessToken: 'access-token' as EncryptedString, + refreshToken: 'refresh-token' as EncryptedString, handle: 'test@example.com', connectionParameters: {}, workspaceId: 'workspace-id', diff --git a/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts index 9bea1919a7..2fd42d190c 100644 --- a/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts +++ b/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts @@ -20,6 +20,7 @@ const CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted'; const CONSTRAINT_EXPR = `"isSecret" = false OR "value" = '' OR "value" LIKE 'enc:v2:%'`; const V2_VARIABLE_KEY = 'TEST_V2_SECRET'; +const V2_NON_SECRET_VARIABLE_KEY = 'TEST_V2_NON_SECRET'; const LEGACY_VARIABLE_KEY = 'TEST_LEGACY_CTR_SECRET'; const buildExpectedMask = (plaintext: string): string => { @@ -63,6 +64,10 @@ describe('ApplicationVariable encryption (integration)', () => { universalIdentifier: crypto.randomUUID(), isSecret: true, }, + [V2_NON_SECRET_VARIABLE_KEY]: { + universalIdentifier: crypto.randomUUID(), + isSecret: false, + }, [LEGACY_VARIABLE_KEY]: { universalIdentifier: crypto.randomUUID(), isSecret: true, @@ -171,6 +176,73 @@ describe('ApplicationVariable encryption (integration)', () => { expect(variable.value).toBe(buildExpectedMask(plaintext)); }); + it('encrypts a non-secret variable on write, persists a v2 envelope, and returns the full decrypted plaintext (not masked) on read', async () => { + const plaintext = 'https://public-url.example.com/webhook'; + + const updateResponse = await makeMetadataAPIRequest({ + query: gql` + mutation UpdateNonSecretAppVarForEncryptionTest( + $key: String! + $value: String! + $applicationId: UUID! + ) { + updateOneApplicationVariable( + key: $key + value: $value + applicationId: $applicationId + ) + } + `, + variables: { + key: V2_NON_SECRET_VARIABLE_KEY, + value: plaintext, + applicationId, + }, + }); + + expect(updateResponse.body.errors).toBeUndefined(); + expect(updateResponse.body.data.updateOneApplicationVariable).toBe(true); + + const [dbRow] = await dataSource.query( + `SELECT "value" + FROM "core"."applicationVariable" + WHERE "applicationId" = $1 AND "key" = $2`, + [applicationId, V2_NON_SECRET_VARIABLE_KEY], + ); + + expect(dbRow.value).not.toContain(plaintext); + expect(dbRow.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe( + true, + ); + expect(dbRow.value).toMatch(V2_ENVELOPE_REGEX); + + const findResponse = await makeMetadataAPIRequest({ + query: gql` + query FindNonSecretAppVarsForEncryptionTest($id: UUID!) { + findOneApplication(id: $id) { + applicationVariables { + key + value + isSecret + } + } + } + `, + variables: { id: applicationId }, + }); + + expect(findResponse.body.errors).toBeUndefined(); + + const variable = + findResponse.body.data.findOneApplication.applicationVariables.find( + (v: { key: string }) => v.key === V2_NON_SECRET_VARIABLE_KEY, + ); + + expect(variable).toBeDefined(); + expect(variable.isSecret).toBe(false); + expect(variable.value).toBe(plaintext); + }); + describe('legacy CTR fallback', () => { beforeAll(async () => { await dataSource.query( diff --git a/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts index 6a3e0aa263..0dc7144a8b 100644 --- a/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts +++ b/packages/twenty-server/test/integration/secret-encryption/connection-parameters-rotation.integration-spec.ts @@ -6,7 +6,7 @@ import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/conn import { runSecretEncryptionRotationCommand } from 'test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; -import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { type EncryptedImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; const V2_ENVELOPE_REGEX = /^enc:v2:[0-9a-f]{8}:[A-Za-z0-9+/=]+$/; @@ -18,7 +18,7 @@ const CALDAV_PASSWORD = 'rotation-test-caldav-password'; type ConnectionParametersRow = { workspaceId: string; - connectionParameters: ImapSmtpCaldavParams; + connectionParameters: EncryptedImapSmtpCaldavParams; }; const readConnectionParameters = async ( @@ -53,9 +53,12 @@ const expectAllPasswordsDecryptTo = ({ expect(params).toBeDefined(); expect(params?.password).toMatch(V2_ENVELOPE_REGEX); - const decrypted = secretEncryption.decryptVersioned(params!.password, { - workspaceId: row.workspaceId, - }); + const decrypted = secretEncryption.decryptVersioned( + params!.password, + { + workspaceId: row.workspaceId, + }, + ); expect(decrypted).toBe(expectedPlaintextByProtocol[protocol]); } diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.integration-spec.ts index f04fa6f62e..f45dac9e3b 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.integration-spec.ts @@ -3,6 +3,7 @@ import { config } from 'dotenv'; import { isDefined } from 'twenty-shared/utils'; import { DataSource } from 'typeorm'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; @@ -209,7 +210,7 @@ describe('2-5 slow instance command 1798000004000 - EncryptConnectedAccountToken const handle = `${TEST_ROW_HANDLE_PREFIX}v2`; const plaintext = 'v2-token'; const preexistingV2Ciphertext = secretEncryptionService.encryptVersioned( - plaintext, + plaintext as PlaintextString, { workspaceId }, ); @@ -246,7 +247,7 @@ describe('2-5 slow instance command 1798000004000 - EncryptConnectedAccountToken const accessPlaintext = 'mixed-access'; const refreshPlaintext = 'mixed-refresh'; const preexistingV2Access = secretEncryptionService.encryptVersioned( - accessPlaintext, + accessPlaintext as PlaintextString, { workspaceId }, ); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts index 2d630ecb18..275b50a0d5 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts @@ -6,6 +6,7 @@ import { DataSource } from 'typeorm'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @@ -186,7 +187,7 @@ describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSl it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-secret'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, { + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString, { workspaceId, }); const id = await seedRow({ isSecret: true, value: preexistingV2 }); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts index 804f78b653..88f65bc24b 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts @@ -6,6 +6,7 @@ import { DataSource } from 'typeorm'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @@ -178,7 +179,7 @@ describe('2-5 slow instance command 1798000006000 - EncryptApplicationRegistrati it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-registration-secret'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext); + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString); const id = await seedVariable({ encryptedValue: preexistingV2 }); await command.runDataMigration(dataSource); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.integration-spec.ts index a0607e22b3..d731fcf6bb 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.integration-spec.ts @@ -5,6 +5,7 @@ import { DataSource } from 'typeorm'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @@ -135,7 +136,7 @@ describe('2-5 slow instance command 1798000007000 - EncryptSigningKeyPrivateKeys it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = '-----BEGIN PRIVATE KEY-----\nalready-v2\n-----END PRIVATE KEY-----'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext); + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString); const id = await seedRow({ privateKey: preexistingV2 }); await command.runDataMigration(dataSource); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts index 9943c266b0..2e6cdd03b2 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts @@ -6,6 +6,8 @@ import { DataSource } from 'typeorm'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @@ -100,12 +102,16 @@ describe('2-5 slow instance command 1798000008000 - EncryptSensitiveConfigStorag const value = await readValue(id); expect(value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(true); - expect(secretEncryptionService.decryptVersioned(value)).toBe(plaintext); + expect( + secretEncryptionService.decryptVersioned( + value as EncryptedString, + ), + ).toBe(plaintext); }); it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'smtp-already-v2-username'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext); + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString); const id = await seedRow(preexistingV2); await command.runDataMigration(dataSource); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts index e568dc0fbb..20bf6d838d 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts @@ -8,6 +8,7 @@ import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/ut import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util'; @@ -195,7 +196,7 @@ describe('2-5 slow instance command 1798000009000 - EncryptTotpSecretsSlowInstan it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-totp-secret'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, { + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString, { workspaceId, }); const id = await seedRow({ secret: preexistingV2 });