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