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