fix: Decrypt encrypted front component variables (#23494)

## Summary

Fixes #23492

Fixes front-component application variables returning their encrypted
at-rest value instead of their configured plaintext value.

Non-secret application variables (`isSecret: false`) are now decrypted
server-side before being injected into the front-component environment.
Secret variables remain excluded and are never decrypted or exposed to
the browser.

## Root cause

The front-component resolver filtered secret application variables
correctly, but forwarded the cached `encryptedValue` directly. As a
result, `getApplicationVariable()` returned an `enc:v2:...` envelope
rather than the configured value.

## Changes

- Decrypt recognized versioned envelopes for non-secret application
variables.
- Preserve empty and legacy/plain values unchanged for backwards
compatibility.
- Add `SecretEncryptionModule` to the front-component module.
- Add coverage for:
  - decrypting public variables;
  - retaining plaintext compatibility;
  - excluding secret variables without attempting decryption.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23494?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Remi Huigen
2026-07-30 18:49:52 +02:00
committed by GitHub
parent a747e62970
commit 830404b215
13 changed files with 500 additions and 432 deletions

View File

@@ -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>,
): 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 = {

View File

@@ -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<Record<string, string>> {
const flatApplicationVariables =
await this.findFlatApplicationVariables(args);
return this.toEnvVariables(flatApplicationVariables);
}
async getPublicEnvVariables(
args: GetEnvVariablesArgs,
): Promise<Record<string, string>> {
const flatApplicationVariables =
await this.findFlatApplicationVariables(args);
return this.toEnvVariables(
flatApplicationVariables.filter(({ isSecret }) => !isSecret),
);
}
private async findFlatApplicationVariables({
workspaceId,
applicationId,
applicationVariableMaps: preloadedApplicationVariableMaps,
}: GetEnvVariablesArgs): Promise<FlatApplicationVariable[]> {
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<string, string> {
return flatApplicationVariables.reduce<Record<string, string>>(
(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,

View File

@@ -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,

View File

@@ -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<LogicFunctionExecuteResult> {
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 ?? '',

View File

@@ -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',
});
});
});

View File

@@ -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<string, string> => {
return flatApplicationVariables.reduce<Record<string, string>>(
(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;
},
{},
);
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>,
): 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({});
});
});

View File

@@ -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<string, string> => {
return flatApplicationVariables.reduce<Record<string, string>>(
(acc, flatApplicationVariable) => {
if (flatApplicationVariable.isSecret) {
return acc;
}
acc[flatApplicationVariable.key] = String(
flatApplicationVariable.value ?? '',
);
return acc;
},
{},
);
};

View File

@@ -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,
});
});
});

View File

@@ -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<PerformMetadataQueryParams<undefined>>) => ({
query: gql`
query FrontComponents {
frontComponents {
${gqlFields}
}
}
`,
});

View File

@@ -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<PerformMetadataQueryParams<undefined>>): 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 };
};