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 d4db112ca65..7490ca6e631 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 @@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { type Repository } from 'typeorm'; +import { FieldMetadataType } from 'twenty-shared/types'; + import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; import { ApplicationVariableEntityException, @@ -10,8 +12,11 @@ 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 ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.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 { 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'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; describe('ApplicationVariableEntityService', () => { @@ -65,6 +70,7 @@ describe('ApplicationVariableEntityService', () => { provide: WorkspaceCacheService, useValue: { invalidateAndRecompute: jest.fn(), + getOrRecompute: jest.fn(), }, }, ], @@ -78,10 +84,206 @@ describe('ApplicationVariableEntityService', () => { workspaceCacheService = module.get(WorkspaceCacheService); }); + const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + const makeFlatVariable = ( + overrides: Partial, + ): FlatApplicationVariable => + ({ + id: '1', + key: 'KEY', + value: '' as EncryptedString | '', + description: '', + isSecret: false, + type: FieldMetadataType.TEXT, + options: null, + applicationId: mockApplicationId, + 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', + ...overrides, + }) as FlatApplicationVariable; + + const makeApplicationVariableMaps = ( + flatApplicationVariables: FlatApplicationVariable[], + ) => + ({ + byUniversalIdentifier: Object.fromEntries( + flatApplicationVariables.map((flatApplicationVariable) => [ + flatApplicationVariable.universalIdentifier, + flatApplicationVariable, + ]), + ), + universalIdentifiersByApplicationId: { + [mockApplicationId]: flatApplicationVariables.map( + ({ universalIdentifier }) => universalIdentifier, + ), + }, + }) as unknown as ApplicationVariableCacheMaps; + + const mockCachedApplicationVariables = ( + flatApplicationVariables: FlatApplicationVariable[], + ) => { + workspaceCacheService.getOrRecompute.mockResolvedValue({ + applicationVariableMaps: makeApplicationVariableMaps( + flatApplicationVariables, + ), + } as never); + }; + it('should be defined', () => { expect(service).toBeDefined(); }); + describe('getServerEnvVariables', () => { + it('should return an empty object when the application has no variable', async () => { + mockCachedApplicationVariables([]); + + await expect( + service.getServerEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + }), + ).resolves.toEqual({}); + }); + + it('should decrypt all encrypted variables regardless of isSecret', async () => { + mockCachedApplicationVariables([ + makeFlatVariable({ + universalIdentifier: 'variable-1', + key: 'PUBLIC_URL', + value: + `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString, + }), + makeFlatVariable({ + universalIdentifier: 'variable-2', + key: 'API_SECRET', + value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString, + isSecret: true, + }), + ]); + + const result = await service.getServerEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + }); + + expect(result).toEqual({ + PUBLIC_URL: 'https://example.com', + API_SECRET: 'secret-123', + }); + expect( + secretEncryptionService.decryptVersionedOrThrow, + ).toHaveBeenCalledTimes(2); + }); + + it('should route each variable to its own workspace HKDF context', async () => { + mockCachedApplicationVariables([ + makeFlatVariable({ + universalIdentifier: 'variable-1', + key: 'A_SECRET', + value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString, + isSecret: true, + }), + makeFlatVariable({ + universalIdentifier: 'variable-2', + key: 'B_SECRET', + value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString, + isSecret: true, + workspaceId: workspaceB, + }), + ]); + + await service.getServerEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + }); + + expect( + secretEncryptionService.decryptVersionedOrThrow, + ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, { + workspaceId: workspaceA, + }); + expect( + secretEncryptionService.decryptVersionedOrThrow, + ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, { + workspaceId: workspaceB, + }); + }); + + it('should return an empty string for uninitialised variables without decrypting', async () => { + mockCachedApplicationVariables([ + makeFlatVariable({ + universalIdentifier: 'variable-1', + key: 'EMPTY_VALUE', + value: '', + }), + ]); + + const result = await service.getServerEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + }); + + expect(result).toEqual({ EMPTY_VALUE: '' }); + expect( + secretEncryptionService.decryptVersionedOrThrow, + ).not.toHaveBeenCalled(); + }); + + it('should reuse provided application variable maps instead of reading the cache', async () => { + const applicationVariableMaps = makeApplicationVariableMaps([ + makeFlatVariable({ + universalIdentifier: 'variable-1', + key: 'PUBLIC_URL', + value: + `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString, + }), + ]); + + const result = await service.getServerEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + applicationVariableMaps, + }); + + expect(result).toEqual({ PUBLIC_URL: 'https://example.com' }); + expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled(); + }); + }); + + describe('getPublicEnvVariables', () => { + it('should exclude secret variables without decrypting them', async () => { + mockCachedApplicationVariables([ + makeFlatVariable({ + universalIdentifier: 'variable-1', + key: 'PUBLIC_URL', + value: + `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString, + }), + makeFlatVariable({ + universalIdentifier: 'variable-2', + key: 'API_SECRET', + value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString, + isSecret: true, + }), + ]); + + const result = await service.getPublicEnvVariables({ + workspaceId: workspaceA, + applicationId: mockApplicationId, + }); + + expect(result).toEqual({ PUBLIC_URL: 'https://example.com' }); + expect( + secretEncryptionService.decryptVersionedOrThrow, + ).toHaveBeenCalledTimes(1); + }); + }); + describe('update', () => { it('should encrypt value with workspaceId-scoped envelope when variable is secret', async () => { const existingVariable = { 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 28c30d4b0dc..7d1b59ec53b 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,10 +10,18 @@ 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 ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.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 { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +type GetEnvVariablesArgs = { + workspaceId: string; + applicationId: string; + applicationVariableMaps?: ApplicationVariableCacheMaps; +}; + @Injectable() export class ApplicationVariableEntityService { constructor( @@ -42,6 +50,80 @@ export class ApplicationVariableEntityService { ); } + async getServerEnvVariables( + args: GetEnvVariablesArgs, + ): Promise> { + const flatApplicationVariables = + await this.findFlatApplicationVariables(args); + + return this.toEnvVariables(flatApplicationVariables); + } + + async getPublicEnvVariables( + args: GetEnvVariablesArgs, + ): Promise> { + const flatApplicationVariables = + await this.findFlatApplicationVariables(args); + + return this.toEnvVariables( + flatApplicationVariables.filter(({ isSecret }) => !isSecret), + ); + } + + private async findFlatApplicationVariables({ + workspaceId, + applicationId, + applicationVariableMaps: preloadedApplicationVariableMaps, + }: GetEnvVariablesArgs): Promise { + const applicationVariableMaps = + preloadedApplicationVariableMaps ?? + ( + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'applicationVariableMaps', + ]) + ).applicationVariableMaps; + + const universalIdentifiers = + applicationVariableMaps.universalIdentifiersByApplicationId[ + applicationId + ] ?? []; + + return universalIdentifiers + .map( + (universalIdentifier) => + applicationVariableMaps.byUniversalIdentifier[universalIdentifier], + ) + .filter(isDefined); + } + + private toEnvVariables( + flatApplicationVariables: FlatApplicationVariable[], + ): Record { + return flatApplicationVariables.reduce>( + (acc, flatApplicationVariable) => { + acc[flatApplicationVariable.key] = this.decryptValue( + flatApplicationVariable, + ); + + return acc; + }, + {}, + ); + } + + private decryptValue({ + value, + workspaceId, + }: FlatApplicationVariable): string { + if (value === '') { + return ''; + } + + return this.secretEncryptionService.decryptVersionedOrThrow(value, { + workspaceId, + }); + } + async update({ key, plainTextValue, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts index 760e42df13f..45461347327 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; import { EventLogLiveModule } from 'src/engine/core-modules/event-logs/live/event-log-live.module'; @@ -29,6 +30,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache FeatureFlagModule, WorkspaceDomainsModule, ApplicationModule, + ApplicationVariableEntityModule, TypeOrmModule.forFeature([ ApplicationRegistrationVariableEntity, WorkspaceEntity, 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 e119ddee170..d3a69b5d559 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 @@ -23,10 +23,11 @@ import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/pro import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed'; +import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service'; +import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type'; import { isBillingExemptApplication } from 'src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util'; import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant'; @@ -35,7 +36,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory'; -import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -85,6 +85,7 @@ export class LogicFunctionExecutorService { private readonly workspaceCacheService: WorkspaceCacheService, private readonly applicationTokenService: ApplicationTokenService, private readonly secretEncryptionService: SecretEncryptionService, + private readonly applicationVariableService: ApplicationVariableEntityService, private readonly subscriptionService: SubscriptionService, private readonly eventLogLiveService: EventLogLiveService, private readonly eventLogEmitterService: EventLogEmitterService, @@ -116,7 +117,7 @@ export class LogicFunctionExecutorService { userWorkspaceId?: string; executionMode?: LogicFunctionExecutionMode; }): Promise { - const { flatApplication, flatLogicFunction, flatApplicationVariables } = + const { flatApplication, flatLogicFunction, applicationVariableMaps } = await this.getFlatEntitiesOrThrow({ workspaceId, logicFunctionId, @@ -131,7 +132,7 @@ export class LogicFunctionExecutorService { const envVariables = await this.getExecutionEnvVariables({ workspaceId, flatApplication, - flatApplicationVariables, + applicationVariableMaps, userId, userWorkspaceId, }); @@ -314,31 +315,19 @@ export class LogicFunctionExecutorService { ); } - const flatApplicationVariableUniversalIdentifiers = - applicationVariableMaps.universalIdentifiersByApplicationId[ - flatApplication.id - ] ?? []; - - const flatApplicationVariables = flatApplicationVariableUniversalIdentifiers - .map( - (universalIdentifier) => - applicationVariableMaps.byUniversalIdentifier[universalIdentifier], - ) - .filter(isDefined); - - return { flatApplication, flatLogicFunction, flatApplicationVariables }; + return { flatApplication, flatLogicFunction, applicationVariableMaps }; } private async getExecutionEnvVariables({ workspaceId, flatApplication, - flatApplicationVariables, + applicationVariableMaps, userId, userWorkspaceId, }: { workspaceId: string; flatApplication: FlatApplication; - flatApplicationVariables: FlatApplicationVariable[]; + applicationVariableMaps: ApplicationVariableCacheMaps; userId?: string; userWorkspaceId?: string; }) { @@ -359,10 +348,12 @@ export class LogicFunctionExecutorService { const serverVariables = await this.buildServerVariableEnvMap( flatApplication.applicationRegistrationId, ); - const workspaceVariables = buildEnvVar( - flatApplicationVariables, - this.secretEncryptionService, - ); + const workspaceVariables = + await this.applicationVariableService.getServerEnvVariables({ + workspaceId, + applicationId: flatApplication.id, + applicationVariableMaps, + }); return { [DEFAULT_API_URL_NAME]: baseUrl ?? '', 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 deleted file mode 100644 index f01e9e1c901..00000000000 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - -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'; - const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; - - const mockSecretEncryptionService = { - encryptVersioned: jest.fn( - (value: string, opts?: { workspaceId?: string }) => - `enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`, - ), - decryptVersionedOrThrow: jest.fn( - (value: string, _opts?: { workspaceId?: string }) => - value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''), - ), - } as unknown as SecretEncryptionService; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return empty object for empty array', () => { - const result = buildEnvVar([], mockSecretEncryptionService); - - expect(result).toEqual({}); - }); - - it('should decrypt all encrypted variables regardless of isSecret', () => { - const flatVariables: FlatApplicationVariable[] = [ - { - id: '1', - key: 'PUBLIC_URL', - value: - `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString, - description: 'Public URL', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - 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: 'API_SECRET', - value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString, - description: 'API secret', - isSecret: true, - type: FieldMetadataType.TEXT, - options: null, - 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: '3', - key: 'DEBUG', - value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString, - description: 'Debug flag', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - 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', - }, - ]; - - const result = buildEnvVar(flatVariables, mockSecretEncryptionService); - - expect(result).toEqual({ - PUBLIC_URL: 'https://example.com', - API_SECRET: 'secret-123', - DEBUG: 'true', - }); - expect( - mockSecretEncryptionService.decryptVersionedOrThrow, - ).toHaveBeenCalledTimes(3); - }); - - 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}` as EncryptedString, - description: '', - isSecret: true, - type: FieldMetadataType.TEXT, - options: null, - 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}` as EncryptedString, - description: '', - isSecret: true, - type: FieldMetadataType.TEXT, - options: null, - 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.decryptVersionedOrThrow, - ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, { - workspaceId: workspaceA, - }); - expect( - mockSecretEncryptionService.decryptVersionedOrThrow, - ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, { - workspaceId: workspaceB, - }); - }); - - it('should handle null or undefined values', () => { - const flatVariables: FlatApplicationVariable[] = [ - { - id: '1', - key: 'NULL_VALUE', - value: null as unknown as EncryptedString | '', - description: '', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - 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: 'UNDEFINED_VALUE', - value: undefined as unknown as EncryptedString | '', - description: '', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - 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', - }, - ]; - - const result = buildEnvVar(flatVariables, mockSecretEncryptionService); - - expect(result).toEqual({ - NULL_VALUE: '', - UNDEFINED_VALUE: '', - }); - }); - - it('should convert non-string values to strings', () => { - const flatVariables: FlatApplicationVariable[] = [ - { - id: '1', - key: 'NUMBER_VALUE', - value: 123 as unknown as EncryptedString | '', - description: '', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - 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', - }, - ]; - - const result = buildEnvVar(flatVariables, mockSecretEncryptionService); - - expect(result).toEqual({ - NUMBER_VALUE: '123', - }); - }); -}); 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 deleted file mode 100644 index 58a862471c1..00000000000 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts +++ /dev/null @@ -1,28 +0,0 @@ -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, -): Record => { - return flatApplicationVariables.reduce>( - (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] = - isNonEmptyString(value) && isEncryptedString(value) - ? secretEncryptionService.decryptVersionedOrThrow(value, { - workspaceId: flatApplicationVariable.workspaceId, - }) - : value; - - return acc; - }, - {}, - ); -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts index 52969ec5704..bf840518f15 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module'; @@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceMigrationModule, ApplicationModule, + ApplicationVariableEntityModule, TokenModule, PermissionsModule, FlatFrontComponentModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts index 42376ad8e34..b5f889f590b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts @@ -2,10 +2,10 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service'; import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -22,8 +22,6 @@ import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/d import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; -import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; @UseGuards(WorkspaceAuthGuard) @@ -38,7 +36,7 @@ export class FrontComponentResolver { private readonly frontComponentService: FrontComponentService, @Inject(ApplicationTokenService) private readonly applicationTokenService: ApplicationTokenService, - private readonly workspaceCacheService: WorkspaceCacheService, + private readonly applicationVariableService: ApplicationVariableEntityService, ) {} @Query(() => [FrontComponentDTO]) @@ -71,26 +69,11 @@ export class FrontComponentResolver { userId: user.id, }); - const { applicationVariableMaps } = - await this.workspaceCacheService.getOrRecompute(workspace.id, [ - 'applicationVariableMaps', - ]); - - const variableUniversalIdentifiers = - applicationVariableMaps.universalIdentifiersByApplicationId[ - dto.applicationId - ] ?? []; - - const flatApplicationVariables = variableUniversalIdentifiers - .map( - (universalIdentifier) => - applicationVariableMaps.byUniversalIdentifier[universalIdentifier], - ) - .filter(isDefined); - - const applicationVariables = stripSecretFromApplicationVariables( - flatApplicationVariables, - ); + const applicationVariables = + await this.applicationVariableService.getPublicEnvVariables({ + workspaceId: workspace.id, + applicationId: dto.applicationId, + }); return { ...dto, 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 deleted file mode 100644 index 927e1711a98..00000000000 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - -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'; - -const makeFlatVariable = ( - overrides: Partial, -): FlatApplicationVariable => ({ - id: '1', - key: 'KEY', - value: 'value' as EncryptedString, - description: '', - isSecret: false, - type: FieldMetadataType.TEXT, - options: null, - applicationId: 'app-1', - workspaceId: '00000000-0000-0000-0000-000000000000', - 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', - ...overrides, -}); - -describe('stripSecretFromApplicationVariables', () => { - it('should return empty object for empty array', () => { - expect(stripSecretFromApplicationVariables([])).toEqual({}); - }); - - it('should include non-secret variables', () => { - const variables = [ - makeFlatVariable({ - key: 'PUBLIC_URL', - value: 'https://example.com' as EncryptedString, - }), - makeFlatVariable({ - id: '2', - key: 'DEBUG', - value: 'true' as EncryptedString, - }), - ]; - - expect(stripSecretFromApplicationVariables(variables)).toEqual({ - PUBLIC_URL: 'https://example.com', - DEBUG: 'true', - }); - }); - - it('should exclude secret variables', () => { - const variables = [ - makeFlatVariable({ - key: 'PUBLIC_URL', - value: 'https://example.com' as EncryptedString, - }), - makeFlatVariable({ - id: '2', - key: 'API_SECRET', - value: 'encrypted_secret' as EncryptedString, - isSecret: true, - }), - makeFlatVariable({ - id: '3', - key: 'DEBUG', - value: 'true' as EncryptedString, - }), - ]; - - const result = stripSecretFromApplicationVariables(variables); - - expect(result).toEqual({ - PUBLIC_URL: 'https://example.com', - DEBUG: 'true', - }); - expect(result).not.toHaveProperty('API_SECRET'); - }); - - it('should handle null and undefined values', () => { - const variables = [ - makeFlatVariable({ - key: 'NULL_VALUE', - value: null as unknown as EncryptedString | '', - }), - makeFlatVariable({ - id: '2', - key: 'UNDEFINED_VALUE', - value: undefined as unknown as EncryptedString | '', - }), - ]; - - expect(stripSecretFromApplicationVariables(variables)).toEqual({ - NULL_VALUE: '', - UNDEFINED_VALUE: '', - }); - }); - - it('should convert non-string values to strings', () => { - const variables = [ - makeFlatVariable({ - key: 'NUMBER_VALUE', - value: 123 as unknown as EncryptedString | '', - }), - ]; - - expect(stripSecretFromApplicationVariables(variables)).toEqual({ - NUMBER_VALUE: '123', - }); - }); - - it('should return empty object when all variables are secret', () => { - const variables = [ - makeFlatVariable({ - key: 'SECRET_1', - value: 'val1' as EncryptedString, - isSecret: true, - }), - makeFlatVariable({ - id: '2', - key: 'SECRET_2', - value: 'val2' as EncryptedString, - isSecret: true, - }), - ]; - - expect(stripSecretFromApplicationVariables(variables)).toEqual({}); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts deleted file mode 100644 index 0bf9a4c6078..00000000000 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; - -export const stripSecretFromApplicationVariables = ( - flatApplicationVariables: FlatApplicationVariable[], -): Record => { - return flatApplicationVariables.reduce>( - (acc, flatApplicationVariable) => { - if (flatApplicationVariable.isSecret) { - return acc; - } - - acc[flatApplicationVariable.key] = String( - flatApplicationVariable.value ?? '', - ); - - return acc; - }, - {}, - ); -}; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts new file mode 100644 index 00000000000..f296c6f12c2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts @@ -0,0 +1,134 @@ +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 { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util'; +import { findFrontComponent } from 'test/integration/metadata/suites/front-component/utils/find-front-component.util'; +import { findFrontComponents } from 'test/integration/metadata/suites/front-component/utils/find-front-components.util'; +import { type Manifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const FRONT_COMPONENT_ID = uuidv4(); +const PUBLIC_VARIABLE_ID = uuidv4(); +const SECRET_VARIABLE_ID = uuidv4(); + +const BUILT_COMPONENT_PATH = 'src/front-components/variables.mjs'; +const PUBLIC_VARIABLE_VALUE = 'pk.public-access-token'; + +const buildManifest = (): Manifest => { + const baseManifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + }); + + return { + ...baseManifest, + application: { + ...baseManifest.application, + applicationVariables: { + PUBLIC_ACCESS_TOKEN: { + universalIdentifier: PUBLIC_VARIABLE_ID, + value: PUBLIC_VARIABLE_VALUE, + }, + API_SECRET: { + universalIdentifier: SECRET_VARIABLE_ID, + isSecret: true, + }, + }, + }, + frontComponents: [ + { + universalIdentifier: FRONT_COMPONENT_ID, + name: 'VariablesComponent', + description: 'A front component reading application variables', + sourceComponentPath: 'src/front-components/variables.tsx', + builtComponentPath: BUILT_COMPONENT_PATH, + builtComponentChecksum: 'variables-checksum', + componentName: 'VariablesComponent', + isHeadless: false, + }, + ], + }; +}; + +describe('Front component application variables', () => { + let frontComponentId: string; + + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application Variables App', + description: 'App for testing front component application variables', + sourcePath: 'test-application-variables', + }); + + jest.useRealTimers(); + + await uploadApplicationFile({ + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'BuiltFrontComponent', + filePath: BUILT_COMPONENT_PATH, + fileBuffer: Buffer.from('dummy built component content'), + filename: 'variables.mjs', + contentType: 'application/javascript', + expectToFail: false, + }); + + jest.useFakeTimers(); + + await syncApplication({ + manifest: buildManifest(), + expectToFail: false, + }); + + const { data } = await findFrontComponents({}); + + const syncedFrontComponent = data.frontComponents.find( + ({ universalIdentifier }) => universalIdentifier === FRONT_COMPONENT_ID, + ); + + if (!isDefined(syncedFrontComponent)) { + throw new Error('Synced front component was not found'); + } + + frontComponentId = syncedFrontComponent.id; + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('should store application variable values encrypted at rest', async () => { + const rows = await globalThis.testDataSource.query( + `SELECT key, value FROM core."applicationVariable" + WHERE "universalIdentifier" = ANY($1)`, + [[PUBLIC_VARIABLE_ID, SECRET_VARIABLE_ID]], + ); + + const publicVariable = rows.find( + ({ key }: { key: string }) => key === 'PUBLIC_ACCESS_TOKEN', + ); + + expect(publicVariable.value).toMatch(/^enc:v2:/); + expect(publicVariable.value).not.toContain(PUBLIC_VARIABLE_VALUE); + }); + + it('should expose non-secret application variables decrypted and exclude secret ones', async () => { + const { data } = await findFrontComponent({ + input: { id: frontComponentId }, + gqlFields: ` + id + applicationVariables + `, + }); + + expect(data.frontComponent.applicationVariables).toEqual({ + PUBLIC_ACCESS_TOKEN: PUBLIC_VARIABLE_VALUE, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts new file mode 100644 index 00000000000..623f2a012b7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +const DEFAULT_FRONT_COMPONENTS_GQL_FIELDS = ` + id + name + universalIdentifier + applicationId +`; + +export const findFrontComponentsQueryFactory = ({ + gqlFields = DEFAULT_FRONT_COMPONENTS_GQL_FIELDS, +}: Partial>) => ({ + query: gql` + query FrontComponents { + frontComponents { + ${gqlFields} + } + } + `, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts new file mode 100644 index 00000000000..f9c625a78fb --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts @@ -0,0 +1,36 @@ +import { findFrontComponentsQueryFactory } from 'test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const findFrontComponents = async ({ + gqlFields, + expectToFail = false, + token, +}: Partial>): CommonResponseBody<{ + frontComponents: FrontComponentDTO[]; +}> => { + const graphqlOperation = findFrontComponentsQueryFactory({ gqlFields }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Finding front components should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Finding front components has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};