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 new file mode 100644 index 0000000000..babaf6545f --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts @@ -0,0 +1,108 @@ +import { isDefined } from 'twenty-shared/utils'; +import { DataSource, QueryRunner } from 'typeorm'; + +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; +}; + +@RegisteredInstanceCommand('2.5.0', 1798000005000, { type: 'slow' }) +export class EncryptApplicationVariableSlowInstanceCommand + implements SlowInstanceCommand +{ + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + // Re-encrypts every secret application variable into the versioned envelope + // bound to its row's workspaceId. Non-secret rows are left untouched — + // their `value` is plaintext by design. Idempotent: the SELECT filter + // skips rows already in v2 form. + async runDataMigration(dataSource: DataSource): Promise { + let cursor = '00000000-0000-0000-0000-000000000000'; + + while (true) { + const rows: ApplicationVariableRow[] = await dataSource.query( + `SELECT id, "workspaceId", "value" + FROM "core"."applicationVariable" + WHERE id > $1 + AND "isSecret" = true + 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; + } + + for (const row of rows) { + // decryptVersioned handles legacy unprefixed CTR ciphertext by + // falling through to the raw-key decrypt path — exactly what we + // need to read the pre-migration rows. + const plaintext = this.secretEncryptionService.decryptVersioned( + row.value, + { workspaceId: row.workspaceId }, + ); + + if (!isDefined(plaintext)) { + continue; + } + + const encryptedValue = this.secretEncryptionService.encryptVersioned( + plaintext, + { workspaceId: row.workspaceId }, + ); + + await dataSource.query( + `UPDATE "core"."applicationVariable" + SET "value" = $2 + WHERE id = $1`, + [row.id, encryptedValue], + ); + } + + cursor = rows[rows.length - 1].id; + } + } + + // The CHECK constraint accepts three cases: + // 1. Non-secret rows (plaintext value, possibly empty) + // 2. Empty secret rows (uninitialised — value defaults to '') + // 3. Secret rows in the versioned envelope + // It is intentionally not strict on the keyId so future key rotations, + // which change the keyId but keep the envelope shape, do not require a + // schema migration. + public async up(queryRunner: QueryRunner): Promise { + 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}')`, + ); + } + + // Deliberately do NOT decrypt rows on rollback — re-introducing plaintext + // secrets to the database would be a security regression. Dropping the + // CHECK constraint is enough; ApplicationVariableEntityService can still + // read the encrypted column whether or not the constraint exists. + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" + DROP CONSTRAINT IF EXISTS "${VALUE_CHECK_CONSTRAINT_NAME}"`, + ); + } +} 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 new file mode 100644 index 0000000000..0fcaf27118 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts @@ -0,0 +1,98 @@ +import { isDefined } from 'twenty-shared/utils'; +import { DataSource, QueryRunner } from 'typeorm'; + +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 ENCRYPTED_VALUE_CHECK_CONSTRAINT_NAME = + 'CHK_applicationRegistrationVariable_encryptedValue_encrypted'; + +const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; + +type ApplicationRegistrationVariableRow = { + id: string; + encryptedValue: string; +}; + +@RegisteredInstanceCommand('2.5.0', 1798000006000, { type: 'slow' }) +export class EncryptApplicationRegistrationVariableSlowInstanceCommand + implements SlowInstanceCommand +{ + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + // Registration variables are server-level config — readable by any + // workspace that installs the parent registration — so they use the + // instance-scoped versioned envelope (no workspaceId in the HKDF info). + // Idempotent: the SELECT filter skips rows already in v2 form and rows + // still in their default '' (unfilled) state. + async runDataMigration(dataSource: DataSource): Promise { + let cursor = '00000000-0000-0000-0000-000000000000'; + + while (true) { + const rows: ApplicationRegistrationVariableRow[] = await dataSource.query( + `SELECT id, "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id > $1 + AND "encryptedValue" <> '' + AND "encryptedValue" NOT LIKE $2 + ORDER BY id + LIMIT $3`, + [cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE], + ); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const plaintext = this.secretEncryptionService.decryptVersioned( + row.encryptedValue, + ); + + if (!isDefined(plaintext)) { + continue; + } + + const encryptedValue = + this.secretEncryptionService.encryptVersioned(plaintext); + + await dataSource.query( + `UPDATE "core"."applicationRegistrationVariable" + SET "encryptedValue" = $2 + WHERE id = $1`, + [row.id, encryptedValue], + ); + } + + cursor = rows[rows.length - 1].id; + } + } + + // The CHECK constraint accepts unfilled rows ('') and rows in the + // versioned envelope. The keyId portion is left unconstrained so future + // ENCRYPTION_KEY rotations do not require a schema migration. + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationRegistrationVariable" + ADD CONSTRAINT "${ENCRYPTED_VALUE_CHECK_CONSTRAINT_NAME}" + CHECK ("encryptedValue" = '' OR "encryptedValue" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, + ); + } + + // Deliberately do NOT decrypt rows on rollback — re-introducing plaintext + // secrets to the database would be a security regression. Dropping the + // CHECK constraint is enough; the service can still read the encrypted + // column whether or not the constraint exists. + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationRegistrationVariable" + DROP CONSTRAINT IF EXISTS "${ENCRYPTED_VALUE_CHECK_CONSTRAINT_NAME}"`, + ); + } +} 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 new file mode 100644 index 0000000000..e307d3556e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts @@ -0,0 +1,95 @@ +import { isDefined } from 'twenty-shared/utils'; +import { DataSource, QueryRunner } from 'typeorm'; + +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 = 200; + +const PRIVATE_KEY_CHECK_CONSTRAINT_NAME = 'CHK_signingKey_privateKey_encrypted'; + +const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; + +type SigningKeyRow = { + id: string; + privateKey: string; +}; + +@RegisteredInstanceCommand('2.5.0', 1798000007000, { type: 'slow' }) +export class EncryptSigningKeyPrivateKeysSlowInstanceCommand + implements SlowInstanceCommand +{ + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + // Signing keys are instance-scoped — every workspace shares the JWKS — so + // the versioned envelope uses no workspaceId in its HKDF info. The + // SELECT filter skips already-migrated rows (idempotent re-runs) and + // NULL privateKey rows (typically revoked or rotated keys). + async runDataMigration(dataSource: DataSource): Promise { + let cursor = '00000000-0000-0000-0000-000000000000'; + + while (true) { + const rows: SigningKeyRow[] = await dataSource.query( + `SELECT id, "privateKey" + FROM "core"."signingKey" + WHERE id > $1 + AND "privateKey" IS NOT NULL + AND "privateKey" NOT LIKE $2 + ORDER BY id + LIMIT $3`, + [cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE], + ); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + const plaintext = this.secretEncryptionService.decryptVersioned( + row.privateKey, + ); + + if (!isDefined(plaintext)) { + continue; + } + + const encryptedPrivateKey = + this.secretEncryptionService.encryptVersioned(plaintext); + + await dataSource.query( + `UPDATE "core"."signingKey" + SET "privateKey" = $2 + WHERE id = $1`, + [row.id, encryptedPrivateKey], + ); + } + + cursor = rows[rows.length - 1].id; + } + } + + // The CHECK constraint admits two states: NULL (revoked keys whose + // private material has been purged) or the versioned envelope. + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."signingKey" + ADD CONSTRAINT "${PRIVATE_KEY_CHECK_CONSTRAINT_NAME}" + CHECK ("privateKey" IS NULL OR "privateKey" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, + ); + } + + // Deliberately do NOT decrypt rows on rollback — re-introducing + // plaintext private keys would be a severe security regression. + // Dropping the CHECK constraint is enough; JwtKeyManagerService can + // still read the encrypted column whether or not the constraint exists. + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."signingKey" + DROP CONSTRAINT IF EXISTS "${PRIVATE_KEY_CHECK_CONSTRAINT_NAME}"`, + ); + } +} 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 new file mode 100644 index 0000000000..1c2add2bcc --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts @@ -0,0 +1,113 @@ +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 { 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'; +import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; +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'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +type SensitiveConfigRow = { id: string; value: unknown }; + +@RegisteredInstanceCommand('2.5.0', 1798000008000, { type: 'slow' }) +export class EncryptSensitiveConfigStorageSlowInstanceCommand + implements SlowInstanceCommand +{ + constructor( + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + // ConfigStorage shares the `keyValuePair.value` (jsonb) column with + // user/feature-flag entries and with non-sensitive config — so a CHECK + // constraint cannot be added column-wide. The backfill walks only the + // CONFIG_VARIABLE rows whose key is declared `isSensitive` + STRING in + // the ConfigVariables metadata, decrypts the legacy CTR ciphertext, and + // re-encrypts it into the instance-scoped versioned envelope. Idempotent: + // already-v2 rows are left untouched. + async runDataMigration(dataSource: DataSource): Promise { + const sensitiveStringKeys = this.collectSensitiveStringConfigKeys(); + + if (sensitiveStringKeys.length === 0) { + return; + } + + for (const key of sensitiveStringKeys) { + const rows: SensitiveConfigRow[] = await dataSource.query( + `SELECT id, value + FROM "core"."keyValuePair" + WHERE type = $1 + AND "userId" IS NULL + AND "workspaceId" IS NULL + AND key = $2`, + [KeyValuePairType.CONFIG_VARIABLE, key], + ); + + for (const row of rows) { + const rawValue = row.value; + + if (typeof rawValue !== 'string') { + continue; + } + + if ( + rawValue === '' || + rawValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX) + ) { + continue; + } + + const plaintext = + this.secretEncryptionService.decryptVersioned(rawValue); + + if (!isDefined(plaintext)) { + continue; + } + + const encrypted = + this.secretEncryptionService.encryptVersioned(plaintext); + + await dataSource.query( + `UPDATE "core"."keyValuePair" + SET value = to_jsonb($1::text) + WHERE id = $2`, + [encrypted, row.id], + ); + } + } + } + + // No CHECK constraint: the jsonb `value` column is heterogeneous (it + // stores booleans, numbers, strings, JSON for both sensitive and + // non-sensitive config plus unrelated user/feature-flag rows), so no + // single CHECK can usefully constrain it. + public async up(_queryRunner: QueryRunner): Promise { + return; + } + + public async down(_queryRunner: QueryRunner): Promise { + return; + } + + private collectSensitiveStringConfigKeys(): string[] { + const metadata = TypedReflect.getMetadata( + 'config-variables', + ConfigVariables.prototype.constructor, + ) as ConfigVariablesMetadataMap | undefined; + + if (!isDefined(metadata)) { + return []; + } + + return Object.entries(metadata) + .filter( + ([, descriptor]) => + descriptor?.isSensitive === true && + descriptor?.type === ConfigVariableType.STRING, + ) + .map(([key]) => key); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts index 2f212ef3f7..c3d4fd861b 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { INSTANCE_COMMANDS } from 'src/database/commands/upgrade-version-command/instance-commands.constant'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; @Module({ - imports: [ConnectedAccountTokenEncryptionModule], + imports: [ConnectedAccountTokenEncryptionModule, SecretEncryptionModule], providers: [...INSTANCE_COMMANDS], }) export class InstanceCommandProviderModule {} 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 ddc779e9d2..e128e0cb91 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 @@ -34,6 +34,10 @@ import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/ 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 { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; +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 { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort'; export const INSTANCE_COMMANDS = [ @@ -71,5 +75,9 @@ export const INSTANCE_COMMANDS = [ AddIsInternalMessagesImportEnabledFastInstanceCommand, CreateSigningKeyTableFastInstanceCommand, EncryptConnectedAccountTokensSlowInstanceCommand, + EncryptApplicationVariableSlowInstanceCommand, + EncryptApplicationRegistrationVariableSlowInstanceCommand, + EncryptSigningKeyPrivateKeysSlowInstanceCommand, + EncryptSensitiveConfigStorageSlowInstanceCommand, AddSubFieldNameToViewSortFastInstanceCommand, ]; 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 e99b4ae781..90d1084e45 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 @@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; import { + Check, Column, CreateDateColumn, Entity, @@ -24,6 +25,13 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati 'applicationRegistrationId', ]) @Index('IDX_APP_REG_VAR_APP_REGISTRATION_ID', ['applicationRegistrationId']) +// Constrains `encryptedValue` to the unfilled default ('') or to the +// versioned envelope. Registration variables are instance-scoped so the +// envelope's HKDF info does not include a workspaceId. +@Check( + 'CHK_applicationRegistrationVariable_encryptedValue_encrypted', + `"encryptedValue" = '' OR "encryptedValue" LIKE 'enc:v2:%'`, +) export class ApplicationRegistrationVariableEntity { @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') 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 c04474ac4b..0835786a0e 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 @@ -46,7 +46,7 @@ export class ApplicationRegistrationVariableService { value: variable.isFilled ? variable.isSecret ? '•••••••••••••' - : this.encryptionService.decrypt(variable.encryptedValue) + : this.encryptionService.decryptVersioned(variable.encryptedValue) : null, })); } @@ -60,7 +60,7 @@ export class ApplicationRegistrationVariableService { workspaceId, ); - const encryptedValue = this.encryptionService.encrypt(input.value); + const encryptedValue = this.encryptionService.encryptVersioned(input.value); const variable = this.variableRepository.create({ applicationRegistrationId: input.applicationRegistrationId, @@ -98,7 +98,9 @@ export class ApplicationRegistrationVariableService { const updateData: Record = {}; if (isDefined(update.value)) { - updateData.encryptedValue = this.encryptionService.encrypt(update.value); + updateData.encryptedValue = this.encryptionService.encryptVersioned( + update.value, + ); } if (isDefined(update.resetValue) && update.resetValue) { 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 4d933fa37e..3ac93232dd 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 @@ -39,17 +39,23 @@ describe('ApplicationVariableEntityService', () => { { provide: SecretEncryptionService, useValue: { - encrypt: jest.fn((value: string) => `encrypted_${value}`), - decrypt: jest.fn((value: string) => - value.replace('encrypted_', ''), + encryptVersioned: jest.fn( + (value: string, opts?: { workspaceId?: string }) => + `enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`, ), - decryptAndMask: jest.fn( + decryptVersioned: jest.fn( + (value: string, _opts?: { workspaceId?: string }) => + value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''), + ), + decryptAndMaskVersioned: jest.fn( ({ value: _value, mask: _mask, + workspaceId: _workspaceId, }: { value: string; mask: string; + workspaceId?: string; }) => '********', ), }, @@ -76,7 +82,7 @@ describe('ApplicationVariableEntityService', () => { }); describe('update', () => { - it('should encrypt value when variable is secret', async () => { + it('should encrypt value with workspaceId-scoped envelope when variable is secret', async () => { const existingVariable = { id: '1', key: 'API_KEY', @@ -95,12 +101,13 @@ describe('ApplicationVariableEntityService', () => { workspaceId: mockWorkspaceId, }); - expect(secretEncryptionService.encrypt).toHaveBeenCalledWith( + expect(secretEncryptionService.encryptVersioned).toHaveBeenCalledWith( 'new-secret-value', + { workspaceId: mockWorkspaceId }, ); expect(repository.update).toHaveBeenCalledWith( { key: 'API_KEY', applicationId: mockApplicationId }, - { value: 'encrypted_new-secret-value' }, + { value: `enc:v2:deadbeef:new-secret-value|${mockWorkspaceId}` }, ); expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith( mockWorkspaceId, @@ -127,7 +134,7 @@ describe('ApplicationVariableEntityService', () => { workspaceId: mockWorkspaceId, }); - expect(secretEncryptionService.encrypt).not.toHaveBeenCalled(); + expect(secretEncryptionService.encryptVersioned).not.toHaveBeenCalled(); expect(repository.update).toHaveBeenCalledWith( { key: 'PUBLIC_URL', applicationId: mockApplicationId }, { value: 'https://new-url.com' }, @@ -167,28 +174,35 @@ describe('ApplicationVariableEntityService', () => { value: 'https://example.com', isSecret: false, applicationId: mockApplicationId, + workspaceId: mockWorkspaceId, } as ApplicationVariableEntity; const result = service.getDisplayValue(variable); expect(result).toBe('https://example.com'); - expect(secretEncryptionService.decryptAndMask).not.toHaveBeenCalled(); + expect( + secretEncryptionService.decryptAndMaskVersioned, + ).not.toHaveBeenCalled(); }); - it('should call decryptAndMask for secret variables', () => { + it('should call decryptAndMaskVersioned with the row workspaceId for secret variables', () => { const variable = { id: '1', key: 'SECRET_KEY', - value: 'encrypted_value', + value: 'enc:v2:deadbeef:secret|workspace-123', isSecret: true, applicationId: mockApplicationId, + workspaceId: mockWorkspaceId, } as ApplicationVariableEntity; service.getDisplayValue(variable); - expect(secretEncryptionService.decryptAndMask).toHaveBeenCalledWith({ - value: 'encrypted_value', + expect( + secretEncryptionService.decryptAndMaskVersioned, + ).toHaveBeenCalledWith({ + value: 'enc:v2:deadbeef:secret|workspace-123', mask: SECRET_APPLICATION_VARIABLE_MASK, + 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 8b571ec561..9000ab2226 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 @@ -2,6 +2,7 @@ import { ObjectType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; import { + Check, Column, CreateDateColumn, Entity, @@ -17,6 +18,14 @@ 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. +@Check( + 'CHK_applicationVariable_value_encrypted', + `"isSecret" = false OR "value" = '' OR "value" LIKE 'enc:v2:%'`, +) export class ApplicationVariableEntity extends SyncableEntity { @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') 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 3f8ce40eee..0382054101 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 @@ -32,9 +32,10 @@ export class ApplicationVariableEntityService { return ''; } - return this.secretEncryptionService.decryptAndMask({ + return this.secretEncryptionService.decryptAndMaskVersioned({ value: applicationVariable.value, mask: SECRET_APPLICATION_VARIABLE_MASK, + workspaceId: applicationVariable.workspaceId, }); } @@ -60,7 +61,9 @@ export class ApplicationVariableEntityService { } const encryptedValue = existingVariable.isSecret - ? this.secretEncryptionService.encrypt(plainTextValue) + ? this.secretEncryptionService.encryptVersioned(plainTextValue, { + workspaceId, + }) : plainTextValue; await this.applicationVariableRepository.update( 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 bbd2f47b71..9fe0b8c247 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 @@ -53,7 +53,7 @@ export class ConnectionProviderService { variables.map((v) => [ v.key, v.encryptedValue - ? this.secretEncryptionService.decrypt(v.encryptedValue) + ? this.secretEncryptionService.decryptVersioned(v.encryptedValue) : '', ]), ); 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 ef931de4d8..dedd940927 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 @@ -1,4 +1,5 @@ import { + Check, Column, CreateDateColumn, Entity, @@ -12,6 +13,12 @@ import { unique: true, where: '"isCurrent" = true', }) +// Signing keys are instance-scoped — the HKDF info is just "instance" +// — so the envelope shape is enforced on every non-null privateKey row. +@Check( + 'CHK_signingKey_privateKey_encrypted', + `"privateKey" IS NULL OR "privateKey" LIKE 'enc:v2:%'`, +) export class SigningKeyEntity { @PrimaryGeneratedColumn('uuid') id: string; 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 99cf184718..4ded09a65c 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 @@ -106,7 +106,7 @@ export class JwtKeyManagerService { ); } - return this.secretEncryptionService.decrypt(encryptedPrivateKey); + return this.secretEncryptionService.decryptVersioned(encryptedPrivateKey); } private async generateAndPersistCurrent(): Promise { @@ -117,7 +117,7 @@ export class JwtKeyManagerService { await this.signingKeyRepository.insert({ id, publicKey: generated.publicKeyPem, - privateKey: this.secretEncryptionService.encrypt( + privateKey: this.secretEncryptionService.encryptVersioned( generated.privateKeyPem, ), isCurrent: true, 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 1116a68eef..cd80acc724 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 @@ -300,8 +300,13 @@ export class LogicFunctionExecutorService { // .updateVariable call encrypt unconditionally), independent of // `isSecret`. `isSecret` is display metadata — the storage contract is // not conditional, so decryption isn't either. + // + // Registration variables are server-level config — any installed + // application across any workspace must be able to read them — so they + // use the instance-scoped versioned envelope (no workspaceId in the HKDF + // info). for (const variable of serverVariables) { - envMap[variable.key] = this.secretEncryptionService.decrypt( + envMap[variable.key] = this.secretEncryptionService.decryptVersioned( variable.encryptedValue, ); } 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 00c22fba69..53cb63cb8a 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 @@ -3,9 +3,18 @@ import { type SecretEncryptionService } from 'src/engine/core-modules/secret-enc import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var'; describe('buildEnvVar', () => { + const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + const mockSecretEncryptionService = { - encrypt: jest.fn((value: string) => `encrypted_${value}`), - decrypt: jest.fn((value: string) => value.replace('encrypted_', '')), + encryptVersioned: jest.fn( + (value: string, opts?: { workspaceId?: string }) => + `enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`, + ), + decryptVersioned: jest.fn( + (value: string, _opts?: { workspaceId?: string }) => + value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''), + ), } as unknown as SecretEncryptionService; beforeEach(() => { @@ -18,7 +27,7 @@ describe('buildEnvVar', () => { expect(result).toEqual({}); }); - it('should handle mixed secret and non-secret variables', () => { + it('should decrypt secret variables with the row workspaceId bound to HKDF', () => { const flatVariables: FlatApplicationVariable[] = [ { id: '1', @@ -27,7 +36,7 @@ describe('buildEnvVar', () => { description: 'Public URL', isSecret: false, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', @@ -36,11 +45,11 @@ describe('buildEnvVar', () => { { id: '2', key: 'API_SECRET', - value: 'encrypted_secret-123', + value: `enc:v2:deadbeef:secret-123|${workspaceA}`, description: 'API secret', isSecret: true, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', @@ -53,7 +62,7 @@ describe('buildEnvVar', () => { description: 'Debug flag', isSecret: false, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', @@ -68,9 +77,54 @@ describe('buildEnvVar', () => { API_SECRET: 'secret-123', DEBUG: 'true', }); - expect(mockSecretEncryptionService.decrypt).toHaveBeenCalledTimes(1); - expect(mockSecretEncryptionService.decrypt).toHaveBeenCalledWith( - 'encrypted_secret-123', + expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledTimes( + 1, + ); + expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith( + `enc:v2:deadbeef:secret-123|${workspaceA}`, + { workspaceId: workspaceA }, + ); + }); + + it('routes each secret variable to its own workspace HKDF context', () => { + const flatVariables: FlatApplicationVariable[] = [ + { + id: '1', + key: 'A_SECRET', + value: `enc:v2:deadbeef:value-a|${workspaceA}`, + description: '', + isSecret: true, + applicationId: 'app-1', + workspaceId: workspaceA, + universalIdentifier: '00000000-0000-0000-0000-000000000000', + applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: '2', + key: 'B_SECRET', + value: `enc:v2:deadbeef:value-b|${workspaceB}`, + description: '', + isSecret: true, + applicationId: 'app-1', + workspaceId: workspaceB, + universalIdentifier: '00000000-0000-0000-0000-000000000000', + applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + + buildEnvVar(flatVariables, mockSecretEncryptionService); + + expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith( + `enc:v2:deadbeef:value-a|${workspaceA}`, + { workspaceId: workspaceA }, + ); + expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith( + `enc:v2:deadbeef:value-b|${workspaceB}`, + { workspaceId: workspaceB }, ); }); @@ -83,7 +137,7 @@ describe('buildEnvVar', () => { description: '', isSecret: false, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', @@ -96,7 +150,7 @@ describe('buildEnvVar', () => { description: '', isSecret: false, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', @@ -121,7 +175,7 @@ describe('buildEnvVar', () => { description: '', isSecret: false, applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', + workspaceId: workspaceA, universalIdentifier: '00000000-0000-0000-0000-000000000000', applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', createdAt: '2024-01-01T00:00:00.000Z', 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 3db2923492..3360441d05 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 @@ -12,7 +12,9 @@ export const buildEnvVar = ( acc[flatApplicationVariable.key] = flatApplicationVariable.isSecret && isNonEmptyString(value) - ? secretEncryptionService.decrypt(value) + ? secretEncryptionService.decryptVersioned(value, { + workspaceId: flatApplicationVariable.workspaceId, + }) : value; return acc; 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 581e11415e..f7ae6ec803 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 @@ -188,4 +188,51 @@ describe('SecretEncryptionService', () => { expect(result).toBe(mask); }); }); + + describe('decryptAndMaskVersioned', () => { + const mask = '********'; + + it('round-trips a v2 envelope and applies the mask', () => { + const secret = 'sk-abcdefghij1234567890'; + const encrypted = service.encryptVersioned(secret); + + const result = service.decryptAndMaskVersioned({ + value: encrypted, + mask, + }); + + // 23 chars, floor(23/10) = 2, min(5, 2) = 2 → first 2 chars + mask + expect(result).toBe(`sk${mask}`); + }); + + 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 result = service.decryptAndMaskVersioned({ + value: encrypted, + mask, + workspaceId, + }); + + // 25 chars, floor(25/10) = 2, min(5, 2) = 2 → first 2 chars + mask + expect(result).toBe(`sk${mask}`); + }); + + it('returns null/undefined values as-is', () => { + expect( + service.decryptAndMaskVersioned({ + value: null as unknown as string, + mask, + }), + ).toBeNull(); + expect( + service.decryptAndMaskVersioned({ + value: undefined as unknown as string, + 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 57ec01b2cf..ff22a424a6 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 @@ -65,7 +65,31 @@ export class SecretEncryptionService { return value; } - const decryptedValue = this.decrypt(value); + return this.maskDecryptedValue(this.decrypt(value), mask); + } + + public decryptAndMaskVersioned({ + value, + mask, + workspaceId, + }: { + value: string; + mask: string; + workspaceId?: string; + }): string { + if (!isDefined(value)) { + return value; + } + + return this.maskDecryptedValue( + this.decryptVersioned(value, { workspaceId }), + mask, + ); + } + + private maskDecryptedValue(decryptedValue: string, mask: string): string { + // Visible-char count caps at 5 and at one-tenth of the secret length, so + // short secrets reveal nothing and longer secrets reveal a stable prefix. const visibleCharsCount = Math.min( 5, Math.floor(decryptedValue.length / 10), 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 85e804d1a5..9ede337da1 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 @@ -82,8 +82,8 @@ describe('ConfigStorageService', () => { { provide: SecretEncryptionService, useValue: { - decrypt: jest.fn((value) => value), - encrypt: jest.fn((value) => value), + decryptVersioned: jest.fn((value) => value), + encryptVersioned: jest.fn((value) => value), }, }, ], @@ -198,7 +198,7 @@ describe('ConfigStorageService', () => { const result = await service.get(key); expect(result).toBe(originalValue); - expect(secretEncryptionService.decrypt).toHaveBeenCalledWith( + expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith( encryptedValue, ); }); @@ -399,7 +399,7 @@ describe('ConfigStorageService', () => { workspaceId: null, type: KeyValuePairType.CONFIG_VARIABLE, }); - expect(secretEncryptionService.encrypt).toHaveBeenCalledWith( + expect(secretEncryptionService.encryptVersioned).toHaveBeenCalledWith( convertedValue, ); }); @@ -570,7 +570,7 @@ describe('ConfigStorageService', () => { expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe( 'normal-value', ); - expect(secretEncryptionService.decrypt).toHaveBeenCalledWith( + expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith( '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 f6de64296b..6522021bf7 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 @@ -70,8 +70,8 @@ export class ConfigStorageService implements ConfigStorageInterface { } return isDecrypt - ? this.secretEncryptionService.decrypt(convertedValue) - : this.secretEncryptionService.encrypt(convertedValue); + ? this.secretEncryptionService.decryptVersioned(convertedValue) + : this.secretEncryptionService.encryptVersioned(convertedValue); } catch (error) { throw new ConfigVariableException( `Failed to convert value for key ${key as string}: ${error.message}`, 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 2c5bfed856..394d4b471b 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 @@ -71,7 +71,9 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr !existing.isSecret ) { (update as Record).value = - this.secretEncryptionService.encrypt(existing.value); + this.secretEncryptionService.encryptVersioned(existing.value, { + workspaceId, + }); } if ( @@ -81,7 +83,9 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr existing.isSecret ) { (update as Record).value = - this.secretEncryptionService.decrypt(existing.value); + this.secretEncryptionService.decryptVersioned(existing.value, { + workspaceId, + }); } await applicationVariableRepository.update( diff --git a/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts index 0d2fb34c7c..0e48f3075d 100644 --- a/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts +++ b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts @@ -1,22 +1,28 @@ +import crypto from 'crypto'; + import gql from 'graphql-tag'; import { isDefined } from 'twenty-shared/utils'; import { type DataSource } from 'typeorm'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; -// Real integration test for the legacy CTR encryption path: drive the -// full create/read/delete lifecycle through the GraphQL API and peek -// into Postgres mid-test to verify the stored value is ciphertext. -// applicationRegistrationVariable uses SecretEncryptionService.encrypt -// (unprefixed CTR), the same legacy path as applicationVariable and -// every other non-connected-account encrypted column. +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +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+/=]+$/; +const CONSTRAINT_NAME = + 'CHK_applicationRegistrationVariable_encryptedValue_encrypted'; +const CONSTRAINT_EXPR = `"encryptedValue" = '' OR "encryptedValue" LIKE 'enc:v2:%'`; describe('ApplicationRegistrationVariable encryption (integration)', () => { let dataSource: DataSource; + let secretEncryption: SecretEncryptionService; let applicationRegistrationId: string; beforeAll(async () => { dataSource = global.testDataSource; + secretEncryption = buildSecretEncryptionServiceFromEnv(); const createRegistrationResponse = await makeMetadataAPIRequest({ query: gql` @@ -62,7 +68,7 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { }); it('encrypts the value on the API write path, persists ciphertext in Postgres, and decrypts back via the API read path', async () => { - const plaintext = 'this-is-a-legacy-ctr-secret-value'; + const plaintext = 'this-is-a-v2-encrypted-secret-value'; const createVariableResponse = await makeMetadataAPIRequest({ query: gql` @@ -77,7 +83,7 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { variables: { input: { applicationRegistrationId, - key: 'TEST_LEGACY_PLAIN', + key: 'TEST_V2_KEY', value: plaintext, isSecret: false, }, @@ -93,11 +99,11 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { [variableId], ); - // The legacy CTR envelope is base64(IV || ciphertext) — no enc: prefix. - // Two invariants: the column does NOT contain the plaintext, and the - // value looks like a base64 blob (proving encryption actually ran). expect(dbRow.encryptedValue).not.toContain(plaintext); - expect(dbRow.encryptedValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect( + dbRow.encryptedValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect(dbRow.encryptedValue).toMatch(V2_ENVELOPE_REGEX); const findResponse = await makeMetadataAPIRequest({ query: gql` @@ -126,9 +132,73 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { expect(variable).toBeDefined(); expect(variable.isSecret).toBe(false); - // For non-secret variables the resolver decrypts and returns the - // plaintext directly — proves the legacy CTR encrypt + decrypt - // round-trip works end-to-end via the live API. expect(variable.value).toBe(plaintext); }); + + describe('legacy CTR fallback', () => { + let legacyVariableId: string; + + beforeAll(async () => { + await dataSource.query( + `ALTER TABLE core."applicationRegistrationVariable" + DROP CONSTRAINT IF EXISTS "${CONSTRAINT_NAME}"`, + ); + }); + + afterAll(async () => { + await dataSource.query( + `DELETE FROM core."applicationRegistrationVariable" WHERE id = $1`, + [legacyVariableId], + ); + await dataSource.query( + `ALTER TABLE core."applicationRegistrationVariable" + ADD CONSTRAINT "${CONSTRAINT_NAME}" CHECK (${CONSTRAINT_EXPR})`, + ); + }); + + it('decrypts a legacy CTR-encrypted value through the live API', async () => { + legacyVariableId = crypto.randomUUID(); + const plaintext = 'legacy-ctr-registration-variable-secret'; + + await dataSource.query( + `INSERT INTO core."applicationRegistrationVariable" + (id, "applicationRegistrationId", "key", "encryptedValue", + "isSecret", "isRequired") + VALUES ($1, $2, 'TEST_LEGACY_CTR_KEY', $3, false, false)`, + [ + legacyVariableId, + applicationRegistrationId, + secretEncryption.encrypt(plaintext), + ], + ); + + const findResponse = await makeMetadataAPIRequest({ + query: gql` + query FindLegacyCtrVariablesForEncryptionTest( + $applicationRegistrationId: String! + ) { + findApplicationRegistrationVariables( + applicationRegistrationId: $applicationRegistrationId + ) { + id + value + isSecret + } + } + `, + variables: { applicationRegistrationId }, + }); + + expect(findResponse.body.errors).toBeUndefined(); + + const variable = + findResponse.body.data.findApplicationRegistrationVariables.find( + (v: { id: string }) => v.id === legacyVariableId, + ); + + expect(variable).toBeDefined(); + expect(variable.isSecret).toBe(false); + expect(variable.value).toBe(plaintext); + }); + }); }); 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 new file mode 100644 index 0000000000..9bea1919a7 --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts @@ -0,0 +1,236 @@ +import crypto from 'crypto'; + +import gql from 'graphql-tag'; +import { isDefined } from 'twenty-shared/utils'; +import { type DataSource } from 'typeorm'; + +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; + +import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +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+/=]+$/; +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 LEGACY_VARIABLE_KEY = 'TEST_LEGACY_CTR_SECRET'; + +const buildExpectedMask = (plaintext: string): string => { + const visibleCharsCount = Math.min(5, Math.floor(plaintext.length / 10)); + + return `${plaintext.slice(0, visibleCharsCount)}${SECRET_APPLICATION_VARIABLE_MASK}`; +}; + +describe('ApplicationVariable encryption (integration)', () => { + let dataSource: DataSource; + let secretEncryption: SecretEncryptionService; + let applicationUniversalIdentifier: string; + let applicationId: string; + + beforeAll(async () => { + dataSource = global.testDataSource; + secretEncryption = buildSecretEncryptionServiceFromEnv(); + + applicationUniversalIdentifier = crypto.randomUUID(); + const roleUniversalIdentifier = crypto.randomUUID(); + + await setupApplicationForSync({ + applicationUniversalIdentifier, + name: 'Test Application', + description: 'App for testing application-variable encryption', + sourcePath: 'test-application-variable-encryption', + }); + + await syncApplication({ + manifest: buildBaseManifest({ + appId: applicationUniversalIdentifier, + roleId: roleUniversalIdentifier, + overrides: { + application: { + universalIdentifier: applicationUniversalIdentifier, + defaultRoleUniversalIdentifier: roleUniversalIdentifier, + displayName: 'Test Application', + description: 'App for testing application-variable encryption', + applicationVariables: { + [V2_VARIABLE_KEY]: { + universalIdentifier: crypto.randomUUID(), + isSecret: true, + }, + [LEGACY_VARIABLE_KEY]: { + universalIdentifier: crypto.randomUUID(), + isSecret: true, + }, + }, + packageJsonChecksum: null, + yarnLockChecksum: null, + }, + }, + }), + expectToFail: false, + }); + + const findResponse = await makeMetadataAPIRequest({ + query: gql` + query FindAppForEncryptionTestSetup($universalIdentifier: UUID!) { + findOneApplication(universalIdentifier: $universalIdentifier) { + id + } + } + `, + variables: { universalIdentifier: applicationUniversalIdentifier }, + }); + + if (!isDefined(findResponse.body?.data?.findOneApplication?.id)) { + throw new Error( + `findOneApplication after sync did not return an id: ${JSON.stringify( + findResponse.body, + )}`, + ); + } + + applicationId = findResponse.body.data.findOneApplication.id; + }, 120000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier, + }); + }); + + it('encrypts the value on the API write path, persists a v2 envelope in Postgres, and returns the masked decrypted value via the API read path', async () => { + const plaintext = 'v2-encrypted-application-variable-secret-value-here'; + + const updateResponse = await makeMetadataAPIRequest({ + query: gql` + mutation UpdateAppVariableForEncryptionTest( + $key: String! + $value: String! + $applicationId: UUID! + ) { + updateOneApplicationVariable( + key: $key + value: $value + applicationId: $applicationId + ) + } + `, + variables: { + key: V2_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_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 FindAppVariablesForEncryptionTest($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_VARIABLE_KEY, + ); + + expect(variable).toBeDefined(); + expect(variable.isSecret).toBe(true); + expect(variable.value).toBe(buildExpectedMask(plaintext)); + }); + + describe('legacy CTR fallback', () => { + beforeAll(async () => { + await dataSource.query( + `ALTER TABLE core."applicationVariable" + DROP CONSTRAINT IF EXISTS "${CONSTRAINT_NAME}"`, + ); + }); + + afterAll(async () => { + await dataSource.query( + `UPDATE core."applicationVariable" + SET "value" = '' + WHERE "applicationId" = $1 AND "key" = $2`, + [applicationId, LEGACY_VARIABLE_KEY], + ); + await dataSource.query( + `ALTER TABLE core."applicationVariable" + ADD CONSTRAINT "${CONSTRAINT_NAME}" CHECK (${CONSTRAINT_EXPR})`, + ); + }); + + it('decrypts a legacy CTR-encrypted value through the live API read path', async () => { + const plaintext = 'legacy-ctr-application-variable-secret-value-here'; + + await dataSource.query( + `UPDATE core."applicationVariable" + SET "value" = $1 + WHERE "applicationId" = $2 AND "key" = $3`, + [ + secretEncryption.encrypt(plaintext), + applicationId, + LEGACY_VARIABLE_KEY, + ], + ); + + const findResponse = await makeMetadataAPIRequest({ + query: gql` + query FindLegacyCtrAppVariablesForEncryptionTest($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 === LEGACY_VARIABLE_KEY, + ); + + expect(variable).toBeDefined(); + expect(variable.isSecret).toBe(true); + expect(variable.value).toBe(buildExpectedMask(plaintext)); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/upgrade/suites/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 similarity index 98% rename from packages/twenty-server/test/integration/upgrade/suites/encrypt-connected-account-tokens.integration-spec.ts rename to packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.integration-spec.ts index f5f7a82c22..f04fa6f62e 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/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 @@ -102,7 +102,7 @@ const restoreEncryptionCheckConstraints = async ( ); }; -describe('EncryptConnectedAccountTokensSlowInstanceCommand (integration)', () => { +describe('2-5 slow instance command 1798000004000 - EncryptConnectedAccountTokensSlowInstanceCommand (integration)', () => { let dataSource: DataSource; let secretEncryptionService: SecretEncryptionService; let connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService; 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 new file mode 100644 index 0000000000..7bdacc3fd1 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts @@ -0,0 +1,244 @@ +import crypto from 'crypto'; + +import { config } from 'dotenv'; +import { isDefined } from 'twenty-shared/utils'; +import { DataSource } from 'typeorm'; + +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.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 { EncryptApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable'; + +jest.useRealTimers(); + +config({ + path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', + override: true, +}); + +const TEST_ROW_KEY_PREFIX = 'ENCRYPT_APP_VAR_TEST_'; +const CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted'; +const CHECK_CONSTRAINT_EXPR = `"isSecret" = false OR "value" = '' OR "value" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`; + +const dropCheckConstraint = (dataSource: DataSource): Promise => + dataSource.query( + `ALTER TABLE "core"."applicationVariable" + DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`, + ); + +const restoreCheckConstraint = async ( + dataSource: DataSource, +): Promise => { + await dropCheckConstraint(dataSource); + await dataSource.query( + `ALTER TABLE "core"."applicationVariable" + ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}" + CHECK (${CHECK_CONSTRAINT_EXPR})`, + ); +}; + +describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSlowInstanceCommand (integration)', () => { + let dataSource: DataSource; + let secretEncryptionService: SecretEncryptionService; + let command: EncryptApplicationVariableSlowInstanceCommand; + let workspaceId: string; + let applicationId: string; + const seededRowIds: string[] = []; + + const seedRow = async ({ + isSecret, + value, + }: { + isSecret: boolean; + value: string; + }): Promise => { + await dropCheckConstraint(dataSource); + + const id = crypto.randomUUID(); + const universalIdentifier = crypto.randomUUID(); + const key = `${TEST_ROW_KEY_PREFIX}${id}`; + + await dataSource.query( + `INSERT INTO "core"."applicationVariable" + (id, "universalIdentifier", "applicationId", "workspaceId", + "key", "value", "isSecret") + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + universalIdentifier, + applicationId, + workspaceId, + key, + value, + isSecret, + ], + ); + + seededRowIds.push(id); + + return id; + }; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: process.env.PG_DATABASE_URL, + schema: 'core', + entities: [], + synchronize: false, + }); + await dataSource.initialize(); + + secretEncryptionService = buildSecretEncryptionServiceFromEnv(); + command = new EncryptApplicationVariableSlowInstanceCommand( + secretEncryptionService, + ); + + const [seedWorkspace] = await dataSource.query( + `SELECT id, "workspaceCustomApplicationId" + FROM "core"."workspace" + WHERE "workspaceCustomApplicationId" IS NOT NULL + LIMIT 1`, + ); + + if (!isDefined(seedWorkspace)) { + throw new Error( + 'No seeded workspace with a custom application found; run database:reset before the integration suite.', + ); + } + + workspaceId = seedWorkspace.id as string; + applicationId = seedWorkspace.workspaceCustomApplicationId as string; + }, 30000); + + afterEach(async () => { + if (seededRowIds.length > 0) { + await dataSource.query( + `DELETE FROM "core"."applicationVariable" WHERE id = ANY($1::uuid[])`, + [seededRowIds], + ); + seededRowIds.length = 0; + } + await restoreCheckConstraint(dataSource); + }); + + afterAll(async () => { + await dataSource?.destroy(); + }); + + it('upgrades legacy CTR secret rows to enc:v2 with workspaceId-bound HKDF', async () => { + const plaintext = 'legacy-ctr-application-variable-secret'; + const id = await seedRow({ + isSecret: true, + value: secretEncryptionService.encrypt(plaintext), + }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe( + true, + ); + expect( + secretEncryptionService.decryptVersioned(row.value, { workspaceId }), + ).toBe(plaintext); + }); + + it('leaves non-secret rows untouched', async () => { + const plaintext = 'https://public.example.com/manifest.json'; + const id = await seedRow({ isSecret: false, value: plaintext }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(row.value).toBe(plaintext); + }); + + it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { + const plaintext = 'already-v2-secret'; + const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, { + workspaceId, + }); + const id = await seedRow({ isSecret: true, value: preexistingV2 }); + + await command.runDataMigration(dataSource); + const [afterFirstRun] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(afterFirstRun.value).toBe(preexistingV2); + + await command.runDataMigration(dataSource); + const [afterSecondRun] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(afterSecondRun.value).toBe(preexistingV2); + }); + + it('up() applies the CHECK constraint that rejects plaintext secret inserts', async () => { + await dropCheckConstraint(dataSource); + + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.up(queryRunner); + + const id = crypto.randomUUID(); + + seededRowIds.push(id); + + await expect( + dataSource.query( + `INSERT INTO "core"."applicationVariable" + (id, "universalIdentifier", "applicationId", "workspaceId", + "key", "value", "isSecret") + VALUES ($1, $2, $3, $4, $5, 'plaintext-should-be-rejected', true)`, + [ + id, + crypto.randomUUID(), + applicationId, + workspaceId, + `${TEST_ROW_KEY_PREFIX}${id}`, + ], + ), + ).rejects.toThrow(/check constraint/i); + } finally { + await queryRunner.release(); + } + }); + + it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => { + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.down(queryRunner); + + const id = await seedRow({ + isSecret: true, + value: 'plaintext-allowed-after-down', + }); + + const [row] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(row.value).toBe('plaintext-allowed-after-down'); + } finally { + await queryRunner.release(); + } + }); +}); 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 new file mode 100644 index 0000000000..804f78b653 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts @@ -0,0 +1,253 @@ +import crypto from 'crypto'; + +import { config } from 'dotenv'; +import { isDefined } from 'twenty-shared/utils'; +import { DataSource } from 'typeorm'; + +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.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 { EncryptApplicationRegistrationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable'; + +jest.useRealTimers(); + +config({ + path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', + override: true, +}); + +const TEST_REGISTRATION_NAME_PREFIX = 'encrypt-app-reg-var-test-'; +const CHECK_CONSTRAINT_NAME = + 'CHK_applicationRegistrationVariable_encryptedValue_encrypted'; +const CHECK_CONSTRAINT_EXPR = `"encryptedValue" = '' OR "encryptedValue" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`; + +const dropCheckConstraint = (dataSource: DataSource): Promise => + dataSource.query( + `ALTER TABLE "core"."applicationRegistrationVariable" + DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`, + ); + +const restoreCheckConstraint = async ( + dataSource: DataSource, +): Promise => { + await dropCheckConstraint(dataSource); + await dataSource.query( + `ALTER TABLE "core"."applicationRegistrationVariable" + ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}" + CHECK (${CHECK_CONSTRAINT_EXPR})`, + ); +}; + +describe('2-5 slow instance command 1798000006000 - EncryptApplicationRegistrationVariableSlowInstanceCommand (integration)', () => { + let dataSource: DataSource; + let secretEncryptionService: SecretEncryptionService; + let command: EncryptApplicationRegistrationVariableSlowInstanceCommand; + let workspaceId: string; + let registrationId: string; + const seededVariableIds: string[] = []; + + const seedVariable = async ({ + encryptedValue, + isSecret = true, + }: { + encryptedValue: string; + isSecret?: boolean; + }): Promise => { + await dropCheckConstraint(dataSource); + + const id = crypto.randomUUID(); + + await dataSource.query( + `INSERT INTO "core"."applicationRegistrationVariable" + (id, "applicationRegistrationId", "key", "encryptedValue", + "isSecret", "isRequired") + VALUES ($1, $2, $3, $4, $5, false)`, + [id, registrationId, `KEY_${id}`, encryptedValue, isSecret], + ); + + seededVariableIds.push(id); + + return id; + }; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: process.env.PG_DATABASE_URL, + schema: 'core', + entities: [], + synchronize: false, + }); + await dataSource.initialize(); + + secretEncryptionService = buildSecretEncryptionServiceFromEnv(); + command = new EncryptApplicationRegistrationVariableSlowInstanceCommand( + secretEncryptionService, + ); + + const [seedWorkspace] = await dataSource.query( + `SELECT id FROM "core"."workspace" LIMIT 1`, + ); + + if (!isDefined(seedWorkspace)) { + throw new Error( + 'No seeded workspace found; run database:reset before the integration suite.', + ); + } + + workspaceId = seedWorkspace.id as string; + + registrationId = crypto.randomUUID(); + + await dataSource.query( + `INSERT INTO "core"."applicationRegistration" + (id, "universalIdentifier", name, "oAuthClientId", + "oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType") + VALUES ($1, $2, $3, $4, $5, $6, $7, 'local')`, + [ + registrationId, + crypto.randomUUID(), + `${TEST_REGISTRATION_NAME_PREFIX}${registrationId}`, + crypto.randomUUID(), + ['http://localhost:3000/callback'], + ['read'], + workspaceId, + ], + ); + }, 30000); + + afterEach(async () => { + if (seededVariableIds.length > 0) { + await dataSource.query( + `DELETE FROM "core"."applicationRegistrationVariable" + WHERE id = ANY($1::uuid[])`, + [seededVariableIds], + ); + seededVariableIds.length = 0; + } + await restoreCheckConstraint(dataSource); + }); + + afterAll(async () => { + await dataSource.query( + `DELETE FROM "core"."applicationRegistration" WHERE id = $1`, + [registrationId], + ); + await dataSource?.destroy(); + }); + + it('upgrades legacy CTR rows to enc:v2 with instance-scoped HKDF', async () => { + const plaintext = 'legacy-ctr-registration-variable-secret'; + const id = await seedVariable({ + encryptedValue: secretEncryptionService.encrypt(plaintext), + }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect( + row.encryptedValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect(secretEncryptionService.decryptVersioned(row.encryptedValue)).toBe( + plaintext, + ); + }); + + it('leaves unfilled rows (encryptedValue = "") untouched', async () => { + const id = await seedVariable({ encryptedValue: '' }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect(row.encryptedValue).toBe(''); + }); + + 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 id = await seedVariable({ encryptedValue: preexistingV2 }); + + await command.runDataMigration(dataSource); + const [afterFirstRun] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect(afterFirstRun.encryptedValue).toBe(preexistingV2); + + await command.runDataMigration(dataSource); + const [afterSecondRun] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect(afterSecondRun.encryptedValue).toBe(preexistingV2); + }); + + it('up() applies the CHECK constraint that rejects plaintext inserts', async () => { + await dropCheckConstraint(dataSource); + + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.up(queryRunner); + + const id = crypto.randomUUID(); + + seededVariableIds.push(id); + + await expect( + dataSource.query( + `INSERT INTO "core"."applicationRegistrationVariable" + (id, "applicationRegistrationId", "key", "encryptedValue", + "isSecret", "isRequired") + VALUES ($1, $2, $3, 'plaintext-should-be-rejected', true, false)`, + [id, registrationId, `KEY_${id}`], + ), + ).rejects.toThrow(/check constraint/i); + } finally { + await queryRunner.release(); + } + }); + + it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => { + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.down(queryRunner); + + const id = await seedVariable({ + encryptedValue: 'plaintext-allowed-after-down', + }); + + const [row] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect(row.encryptedValue).toBe('plaintext-allowed-after-down'); + } finally { + await queryRunner.release(); + } + }); +}); 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 new file mode 100644 index 0000000000..a0607e22b3 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.integration-spec.ts @@ -0,0 +1,201 @@ +import crypto from 'crypto'; + +import { config } from 'dotenv'; +import { DataSource } from 'typeorm'; + +import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.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 { EncryptSigningKeyPrivateKeysSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys'; + +jest.useRealTimers(); + +config({ + path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', + override: true, +}); + +const PUBLIC_KEY_FIXTURE = + '-----BEGIN PUBLIC KEY-----\nintegration-test-public\n-----END PUBLIC KEY-----'; +const CHECK_CONSTRAINT_NAME = 'CHK_signingKey_privateKey_encrypted'; +const CHECK_CONSTRAINT_EXPR = `"privateKey" IS NULL OR "privateKey" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`; + +const dropCheckConstraint = (dataSource: DataSource): Promise => + dataSource.query( + `ALTER TABLE "core"."signingKey" + DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`, + ); + +const restoreCheckConstraint = async ( + dataSource: DataSource, +): Promise => { + await dropCheckConstraint(dataSource); + await dataSource.query( + `ALTER TABLE "core"."signingKey" + ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}" + CHECK (${CHECK_CONSTRAINT_EXPR})`, + ); +}; + +describe('2-5 slow instance command 1798000007000 - EncryptSigningKeyPrivateKeysSlowInstanceCommand (integration)', () => { + let dataSource: DataSource; + let secretEncryptionService: SecretEncryptionService; + let command: EncryptSigningKeyPrivateKeysSlowInstanceCommand; + const seededRowIds: string[] = []; + + const seedRow = async ({ + privateKey, + }: { + privateKey: string | null; + }): Promise => { + await dropCheckConstraint(dataSource); + + const id = crypto.randomUUID(); + + await dataSource.query( + `INSERT INTO "core"."signingKey" + (id, "publicKey", "privateKey", "isCurrent") + VALUES ($1, $2, $3, false)`, + [id, PUBLIC_KEY_FIXTURE, privateKey], + ); + + seededRowIds.push(id); + + return id; + }; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: process.env.PG_DATABASE_URL, + schema: 'core', + entities: [], + synchronize: false, + }); + await dataSource.initialize(); + + secretEncryptionService = buildSecretEncryptionServiceFromEnv(); + command = new EncryptSigningKeyPrivateKeysSlowInstanceCommand( + secretEncryptionService, + ); + }, 30000); + + afterEach(async () => { + if (seededRowIds.length > 0) { + await dataSource.query( + `DELETE FROM "core"."signingKey" WHERE id = ANY($1::uuid[])`, + [seededRowIds], + ); + seededRowIds.length = 0; + } + await restoreCheckConstraint(dataSource); + }); + + afterAll(async () => { + await dataSource?.destroy(); + }); + + it('upgrades a legacy CTR-encrypted private key to enc:v2 with instance-scoped HKDF', async () => { + const plaintextPem = + '-----BEGIN PRIVATE KEY-----\nlegacy-pem-material\n-----END PRIVATE KEY-----'; + const id = await seedRow({ + privateKey: secretEncryptionService.encrypt(plaintextPem), + }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`, + [id], + ); + + expect( + row.privateKey.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect(secretEncryptionService.decryptVersioned(row.privateKey)).toBe( + plaintextPem, + ); + }); + + it('leaves NULL private keys untouched (revoked / rotated keys)', async () => { + const id = await seedRow({ privateKey: null }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`, + [id], + ); + + expect(row.privateKey).toBeNull(); + }); + + 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 id = await seedRow({ privateKey: preexistingV2 }); + + await command.runDataMigration(dataSource); + const [afterFirstRun] = await dataSource.query( + `SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`, + [id], + ); + + expect(afterFirstRun.privateKey).toBe(preexistingV2); + + await command.runDataMigration(dataSource); + const [afterSecondRun] = await dataSource.query( + `SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`, + [id], + ); + + expect(afterSecondRun.privateKey).toBe(preexistingV2); + }); + + it('up() applies the CHECK constraint that rejects plaintext inserts', async () => { + await dropCheckConstraint(dataSource); + + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.up(queryRunner); + + const id = crypto.randomUUID(); + + seededRowIds.push(id); + + await expect( + dataSource.query( + `INSERT INTO "core"."signingKey" + (id, "publicKey", "privateKey", "isCurrent") + VALUES ($1, $2, 'plaintext-should-be-rejected', false)`, + [id, PUBLIC_KEY_FIXTURE], + ), + ).rejects.toThrow(/check constraint/i); + } finally { + await queryRunner.release(); + } + }); + + it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => { + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.down(queryRunner); + + const id = await seedRow({ privateKey: 'plaintext-allowed-after-down' }); + + const [row] = await dataSource.query( + `SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`, + [id], + ); + + expect(row.privateKey).toBe('plaintext-allowed-after-down'); + } finally { + await queryRunner.release(); + } + }); +}); 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 new file mode 100644 index 0000000000..9943c266b0 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts @@ -0,0 +1,125 @@ +import crypto from 'crypto'; + +import { config } from 'dotenv'; +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 { 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 { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage'; + +jest.useRealTimers(); + +config({ + path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', + override: true, +}); + +const SENSITIVE_STRING_KEY = 'EMAIL_SMTP_USER'; + +describe('2-5 slow instance command 1798000008000 - EncryptSensitiveConfigStorageSlowInstanceCommand (integration)', () => { + let dataSource: DataSource; + let secretEncryptionService: SecretEncryptionService; + let command: EncryptSensitiveConfigStorageSlowInstanceCommand; + const seededRowIds: string[] = []; + + const clearSeededKey = (): Promise => + dataSource.query( + `DELETE FROM "core"."keyValuePair" + WHERE type = $1 AND key = $2 + AND "userId" IS NULL AND "workspaceId" IS NULL`, + [KeyValuePairType.CONFIG_VARIABLE, SENSITIVE_STRING_KEY], + ); + + const seedRow = async (value: string): Promise => { + const id = crypto.randomUUID(); + + await dataSource.query( + `INSERT INTO "core"."keyValuePair" + (id, "userId", "workspaceId", key, value, type) + VALUES ($1, NULL, NULL, $2, to_jsonb($3::text), $4)`, + [id, SENSITIVE_STRING_KEY, value, KeyValuePairType.CONFIG_VARIABLE], + ); + + seededRowIds.push(id); + + return id; + }; + + const readValue = async (id: string): Promise => { + const [row] = await dataSource.query( + `SELECT value FROM "core"."keyValuePair" WHERE id = $1`, + [id], + ); + + return row.value as string; + }; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: process.env.PG_DATABASE_URL, + schema: 'core', + entities: [], + synchronize: false, + }); + await dataSource.initialize(); + + secretEncryptionService = buildSecretEncryptionServiceFromEnv(); + command = new EncryptSensitiveConfigStorageSlowInstanceCommand( + secretEncryptionService, + ); + + await clearSeededKey(); + }, 30000); + + afterEach(async () => { + if (seededRowIds.length > 0) { + await dataSource.query( + `DELETE FROM "core"."keyValuePair" WHERE id = ANY($1::uuid[])`, + [seededRowIds], + ); + seededRowIds.length = 0; + } + }); + + afterAll(async () => { + await clearSeededKey(); + await dataSource?.destroy(); + }); + + it('upgrades a legacy CTR sensitive STRING config row to enc:v2 with instance-scoped HKDF', async () => { + const plaintext = 'smtp-legacy-username'; + const id = await seedRow(secretEncryptionService.encrypt(plaintext)); + + await command.runDataMigration(dataSource); + + const value = await readValue(id); + + expect(value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(true); + expect(secretEncryptionService.decryptVersioned(value)).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 id = await seedRow(preexistingV2); + + await command.runDataMigration(dataSource); + expect(await readValue(id)).toBe(preexistingV2); + + await command.runDataMigration(dataSource); + expect(await readValue(id)).toBe(preexistingV2); + }); + + it('leaves empty sensitive config rows untouched', async () => { + const id = await seedRow(''); + + await command.runDataMigration(dataSource); + + expect(await readValue(id)).toBe(''); + }); +}); diff --git a/packages/twenty-server/test/integration/upgrade/utils/build-secret-encryption-service.util.ts b/packages/twenty-server/test/integration/upgrade/utils/build-secret-encryption-service.util.ts new file mode 100644 index 0000000000..7bd05d41b1 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/utils/build-secret-encryption-service.util.ts @@ -0,0 +1,21 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +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'; + +export const buildSecretEncryptionServiceFromEnv = + (): SecretEncryptionService => { + const appSecret = process.env.APP_SECRET; + + if (!isNonEmptyString(appSecret)) { + throw new Error( + 'APP_SECRET must be set in the integration test environment to run encryption backfill suites.', + ); + } + + const driver = { + get: (key: string) => process.env[key], + } as unknown as EnvironmentConfigDriver; + + return new SecretEncryptionService(driver); + };