Encrypt/decrypt app secret variables (#17394)

Closes https://github.com/twentyhq/core-team-issues/issues/1724
From PR https://github.com/twentyhq/twenty/pull/15283, followed same
implementation
- Introduce EnvironmentModule to provide type safety for env-only
variables
- Encrypt secret variables
- When querying app secret variable, display up to first 5 characters
(depending on secret length) then `******`
This commit is contained in:
Marie 2026-01-27 16:59:37 +01:00 committed by GitHub
parent a913ac7452
commit 7e3d9cd85a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 865 additions and 156 deletions

View file

@ -4,7 +4,6 @@ import {
Module,
RequestMethod,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { ServeStaticModule } from '@nestjs/serve-static';
@ -48,10 +47,6 @@ const MIGRATED_REST_METHODS = [
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
}),
GraphQLModule.forRootAsync<YogaDriverConfig>({
driver: YogaDriver,
imports: [GraphQLConfigModule, MetricsModule, DataloaderModule],

View file

@ -0,0 +1,329 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import {
ApplicationVariableEntityException,
ApplicationVariableEntityExceptionCode,
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/applicationVariable/constants/secret-application-variable-mask.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('ApplicationVariableEntityService', () => {
let service: ApplicationVariableEntityService;
let repository: jest.Mocked<Repository<ApplicationVariableEntity>>;
let secretEncryptionService: jest.Mocked<SecretEncryptionService>;
let workspaceCacheService: jest.Mocked<WorkspaceCacheService>;
const mockWorkspaceId = 'workspace-123';
const mockApplicationId = 'app-456';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationVariableEntityService,
{
provide: getRepositoryToken(ApplicationVariableEntity),
useValue: {
findOne: jest.fn(),
update: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
},
},
{
provide: SecretEncryptionService,
useValue: {
encrypt: jest.fn((value: string) => `encrypted_${value}`),
decrypt: jest.fn((value: string) =>
value.replace('encrypted_', ''),
),
decryptAndMask: jest.fn(
({
value: _value,
mask: _mask,
}: {
value: string;
mask: string;
}) => '********',
),
},
},
{
provide: WorkspaceCacheService,
useValue: {
invalidateAndRecompute: jest.fn(),
},
},
],
}).compile();
service = module.get<ApplicationVariableEntityService>(
ApplicationVariableEntityService,
);
repository = module.get(getRepositoryToken(ApplicationVariableEntity));
secretEncryptionService = module.get(SecretEncryptionService);
workspaceCacheService = module.get(WorkspaceCacheService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('update', () => {
it('should encrypt value when variable is secret', async () => {
const existingVariable = {
id: '1',
key: 'API_KEY',
value: 'old-encrypted-value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.findOne.mockResolvedValue(existingVariable);
repository.update.mockResolvedValue({ affected: 1 } as any);
await service.update({
key: 'API_KEY',
plainTextValue: 'new-secret-value',
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith(
'new-secret-value',
);
expect(repository.update).toHaveBeenCalledWith(
{ key: 'API_KEY', applicationId: mockApplicationId },
{ value: 'encrypted_new-secret-value' },
);
expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith(
mockWorkspaceId,
['applicationVariableMaps'],
);
});
it('should not encrypt value when variable is not secret', async () => {
const existingVariable = {
id: '1',
key: 'PUBLIC_URL',
value: 'https://old-url.com',
isSecret: false,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.findOne.mockResolvedValue(existingVariable);
repository.update.mockResolvedValue({ affected: 1 } as any);
await service.update({
key: 'PUBLIC_URL',
plainTextValue: 'https://new-url.com',
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
expect(repository.update).toHaveBeenCalledWith(
{ key: 'PUBLIC_URL', applicationId: mockApplicationId },
{ value: 'https://new-url.com' },
);
});
it('should throw exception when variable not found', async () => {
repository.findOne.mockResolvedValue(null);
await expect(
service.update({
key: 'NON_EXISTENT',
plainTextValue: 'some-value',
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApplicationVariableEntityException);
await expect(
service.update({
key: 'NON_EXISTENT',
plainTextValue: 'some-value',
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
});
});
});
describe('upsertManyApplicationVariableEntities', () => {
it('should encrypt secret values when creating new variables', async () => {
repository.findOne.mockResolvedValue(null);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
SECRET_KEY: {
universalIdentifier: 'secret-key-123',
value: 'my-secret',
description: 'A secret key',
isSecret: true,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith('my-secret');
expect(repository.save).toHaveBeenCalledWith({
key: 'SECRET_KEY',
value: 'encrypted_my-secret',
description: 'A secret key',
isSecret: true,
applicationId: mockApplicationId,
});
});
it('should not encrypt non-secret values when creating new variables', async () => {
repository.findOne.mockResolvedValue(null);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
PUBLIC_URL: {
universalIdentifier: 'public-url-123',
value: 'https://example.com',
description: 'Public URL',
isSecret: false,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
expect(repository.save).toHaveBeenCalledWith({
key: 'PUBLIC_URL',
value: 'https://example.com',
description: 'Public URL',
isSecret: false,
applicationId: mockApplicationId,
});
});
it('should handle undefined isSecret as false', async () => {
repository.findOne.mockResolvedValue(null);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
SOME_VAR: {
universalIdentifier: 'some-var-123',
value: 'some-value',
description: 'Some variable',
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
isSecret: false,
}),
);
});
it('should update existing variables without changing values', async () => {
const existingVariable = {
id: '1',
key: 'EXISTING_VAR',
value: 'existing-encrypted-value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.findOne.mockResolvedValue(existingVariable);
repository.update.mockResolvedValue({ affected: 1 } as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
EXISTING_VAR: {
universalIdentifier: 'existing-var-123',
value: 'new-value',
description: 'Updated description',
isSecret: true,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(repository.update).toHaveBeenCalledWith(
{ key: 'EXISTING_VAR', applicationId: mockApplicationId },
{
description: 'Updated description',
isSecret: true,
value: 'encrypted_new-value',
},
);
expect(repository.save).not.toHaveBeenCalled();
});
it('should handle undefined applicationVariables', async () => {
await service.upsertManyApplicationVariableEntities({
applicationVariables: undefined,
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(repository.findOne).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
expect(repository.update).not.toHaveBeenCalled();
expect(
workspaceCacheService.invalidateAndRecompute,
).not.toHaveBeenCalled();
});
});
describe('getDisplayValue', () => {
it('should return plain value for non-secret variables', () => {
const variable = {
id: '1',
key: 'PUBLIC_URL',
value: 'https://example.com',
isSecret: false,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
const result = service.getDisplayValue(variable);
expect(result).toBe('https://example.com');
expect(secretEncryptionService.decryptAndMask).not.toHaveBeenCalled();
});
it('should call decryptAndMask for secret variables', () => {
const variable = {
id: '1',
key: 'SECRET_KEY',
value: 'encrypted_value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
service.getDisplayValue(variable);
expect(secretEncryptionService.decryptAndMask).toHaveBeenCalledWith({
value: 'encrypted_value',
mask: SECRET_APPLICATION_VARIABLE_MASK,
});
});
});
});

View file

@ -7,6 +7,7 @@ import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVa
import { ApplicationVariableEntityResolver } from 'src/engine/core-modules/applicationVariable/application-variable.resolver';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { WorkspaceApplicationVariableMapCacheService } from 'src/engine/core-modules/applicationVariable/services/workspace-application-variable-map-cache.service';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@ -16,6 +17,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
TypeOrmModule.forFeature([ApplicationVariableEntity]),
PermissionsModule,
WorkspaceCacheModule,
SecretEncryptionModule,
],
providers: [
ApplicationVariableEntityService,

View file

@ -1,13 +1,21 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import {
Args,
Mutation,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApplicationVariableEntityExceptionFilter } from 'src/engine/core-modules/applicationVariable/application-variable-exception-filter';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
import { UpdateApplicationVariableEntityInput } from 'src/engine/core-modules/applicationVariable/dtos/update-application-variable.input';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@ -15,13 +23,18 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@Resolver()
@Resolver(() => ApplicationVariableEntityDTO)
@UseFilters(ApplicationVariableEntityExceptionFilter)
export class ApplicationVariableEntityResolver {
constructor(
private readonly applicationVariableService: ApplicationVariableEntityService,
) {}
@ResolveField(() => String)
value(@Parent() applicationVariable: ApplicationVariableEntity): string {
return this.applicationVariableService.getDisplayValue(applicationVariable);
}
@Mutation(() => Boolean)
async updateOneApplicationVariable(
@Args() { key, value, applicationId }: UpdateApplicationVariableEntityInput,
@ -29,7 +42,7 @@ export class ApplicationVariableEntityResolver {
) {
await this.applicationVariableService.update({
key,
value,
plainTextValue: value,
applicationId,
workspaceId,
});

View file

@ -1,11 +1,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ApplicationVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, Repository } from 'typeorm';
import { ApplicationVariables } from 'twenty-shared/application';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import {
ApplicationVariableEntityException,
ApplicationVariableEntityExceptionCode,
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/applicationVariable/constants/secret-application-variable-mask.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
@ -14,21 +20,58 @@ export class ApplicationVariableEntityService {
@InjectRepository(ApplicationVariableEntity)
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
private encryptSecretValue(value: string, isSecret: boolean): string {
if (!isSecret) {
return value;
}
return this.secretEncryptionService.encrypt(value);
}
getDisplayValue(applicationVariable: ApplicationVariableEntity): string {
if (!applicationVariable.isSecret) {
return applicationVariable.value;
}
return this.secretEncryptionService.decryptAndMask({
value: applicationVariable.value,
mask: SECRET_APPLICATION_VARIABLE_MASK,
});
}
async update({
key,
value,
plainTextValue,
applicationId,
workspaceId,
}: Pick<ApplicationVariableEntity, 'key' | 'value'> & {
}: Pick<ApplicationVariableEntity, 'key'> & {
applicationId: string;
workspaceId: string;
plainTextValue: string;
}) {
const existingVariable = await this.applicationVariableRepository.findOne({
where: { key, applicationId },
});
if (!isDefined(existingVariable)) {
throw new ApplicationVariableEntityException(
`Application variable with key ${key} not found`,
ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
);
}
const encryptedValue = this.encryptSecretValue(
plainTextValue,
existingVariable.isSecret,
);
await this.applicationVariableRepository.update(
{ key, applicationId },
{
value,
value: encryptedValue,
},
);
@ -53,6 +96,12 @@ export class ApplicationVariableEntityService {
for (const [key, { value, description, isSecret }] of Object.entries(
applicationVariables,
)) {
const isSecretValue = isSecret ?? false;
const encryptedValue = this.encryptSecretValue(
value ?? '',
isSecretValue,
);
if (
await this.applicationVariableRepository.findOne({
where: {
@ -67,16 +116,17 @@ export class ApplicationVariableEntityService {
applicationId,
},
{
description,
isSecret,
value: encryptedValue,
description: description ?? '',
isSecret: isSecretValue,
},
);
} else {
await this.applicationVariableRepository.save({
key,
value,
description,
isSecret,
value: encryptedValue,
description: description ?? '',
isSecret: isSecretValue,
applicationId,
});
}

View file

@ -0,0 +1 @@
export const SECRET_APPLICATION_VARIABLE_MASK = '********';

View file

@ -9,6 +9,7 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingWebhookModule } from 'src/engine/core-modules/billing-webhook/billing-webhook.module';
@ -75,6 +76,7 @@ import { FileModule } from './file/file.module';
@Module({
imports: [
EnvironmentModule,
TwentyConfigModule.forRoot(),
HealthModule,
AuditModule,

View file

@ -0,0 +1,30 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import {
ConfigVariables,
validate,
} from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
}),
],
providers: [
EnvironmentConfigDriver,
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(),
},
],
exports: [EnvironmentConfigDriver],
})
export class EnvironmentModule {}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Module({
providers: [SecretEncryptionService],
exports: [SecretEncryptionService],
})
export class SecretEncryptionModule {}

View file

@ -0,0 +1,191 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { SecretEncryptionService } from './secret-encryption.service';
describe('SecretEncryptionService', () => {
let service: SecretEncryptionService;
const mockAppSecret = 'mock-app-secret-for-testing-purposes-12345678';
const testValue = 'my-secret-api-key-123';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SecretEncryptionService,
{
provide: EnvironmentConfigDriver,
useValue: {
get: jest.fn().mockReturnValue(mockAppSecret),
},
},
],
}).compile();
service = module.get<SecretEncryptionService>(SecretEncryptionService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('encrypt and decrypt', () => {
it('should encrypt and decrypt a value correctly', () => {
const encrypted = service.encrypt(testValue);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(testValue);
expect(encrypted).not.toBe(testValue);
});
it('should generate different encrypted values for the same input (due to random IV)', () => {
const encrypted1 = service.encrypt(testValue);
const encrypted2 = service.encrypt(testValue);
expect(encrypted1).not.toBe(encrypted2);
const decrypted1 = service.decrypt(encrypted1);
const decrypted2 = service.decrypt(encrypted2);
expect(decrypted1).toBe(testValue);
expect(decrypted2).toBe(testValue);
});
it('should handle special characters in values', () => {
const specialValue = 'api-key_WITH@special#chars!123$%^&*()';
const encrypted = service.encrypt(specialValue);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(specialValue);
});
it('should handle empty strings', () => {
const emptyValue = '';
const encrypted = service.encrypt(emptyValue);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(emptyValue);
});
it('should handle long values', () => {
const longValue = 'a'.repeat(1000);
const encrypted = service.encrypt(longValue);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(longValue);
});
it('should handle unicode characters', () => {
const unicodeValue = 'secret-with-émojis-🔐-and-中文';
const encrypted = service.encrypt(unicodeValue);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(unicodeValue);
});
});
describe('encrypt edge cases', () => {
it('should return undefined values as-is', () => {
const result = service.encrypt(undefined as unknown as string);
expect(result).toBeUndefined();
});
it('should return null values as-is', () => {
const result = service.encrypt(null as unknown as string);
expect(result).toBeNull();
});
});
describe('decrypt edge cases', () => {
it('should return undefined values as-is', () => {
const result = service.decrypt(undefined as unknown as string);
expect(result).toBeUndefined();
});
it('should return null values as-is', () => {
const result = service.decrypt(null as unknown as string);
expect(result).toBeNull();
});
});
describe('decryptAndMask', () => {
const mask = '********';
it('should return undefined values as-is', () => {
const result = service.decryptAndMask({
value: undefined as unknown as string,
mask,
});
expect(result).toBeUndefined();
});
it('should return null values as-is', () => {
const result = service.decryptAndMask({
value: null as unknown as string,
mask,
});
expect(result).toBeNull();
});
it('should return only mask for short secrets (length <= 10)', () => {
const shortSecret = 'value';
const encrypted = service.encrypt(shortSecret);
const result = service.decryptAndMask({ value: encrypted, mask });
// "value" has 5 chars, floor(5/10) = 0, min(5, 0) = 0, slice(0,0) = ""
expect(result).toBe(mask);
});
it('should return partial value + mask for medium secrets (10 < length <= 50)', () => {
const mediumSecret = 'sk-abc123def456';
const encrypted = service.encrypt(mediumSecret);
const result = service.decryptAndMask({ value: encrypted, mask });
// "sk-abc123def456" has 15 chars, floor(15/10) = 1, min(5, 1) = 1
expect(result).toBe(`s${mask}`);
});
it('should return partial value + mask for longer secrets', () => {
const longerSecret = 'sk-abcdefghij1234567890';
const encrypted = service.encrypt(longerSecret);
const result = service.decryptAndMask({ value: encrypted, mask });
// "sk-abcdefghij1234567890" has 23 chars, floor(23/10) = 2, min(5, 2) = 2
expect(result).toBe(`sk${mask}`);
});
it('should cap visible chars at 5 for very long secrets', () => {
const veryLongSecret = 'a'.repeat(100);
const encrypted = service.encrypt(veryLongSecret);
const result = service.decryptAndMask({ value: encrypted, mask });
// 100 chars, floor(100/10) = 10, min(5, 10) = 5
expect(result).toBe(`aaaaa${mask}`);
});
it('should handle empty secret values', () => {
const emptySecret = '';
const encrypted = service.encrypt(emptySecret);
const result = service.decryptAndMask({ value: encrypted, mask });
// "" has 0 chars, floor(0/10) = 0, min(5, 0) = 0
expect(result).toBe(mask);
});
});
});

View file

@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
@Injectable()
export class SecretEncryptionService {
constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver,
) {}
private getAppSecret(): string {
return this.environmentConfigDriver.get('APP_SECRET');
}
public encrypt(value: string): string {
if (!isDefined(value)) {
return value;
}
const appSecret = this.getAppSecret();
return encryptText(value, appSecret);
}
public decrypt(value: string): string {
if (!isDefined(value)) {
return value;
}
const appSecret = this.getAppSecret();
return decryptText(value, appSecret);
}
public decryptAndMask({
value,
mask,
}: {
value: string;
mask: string;
}): string {
if (!isDefined(value)) {
return value;
}
const decryptedValue = this.decrypt(value);
const visibleCharsCount = Math.min(
5,
Math.floor(decryptedValue.length / 10),
);
return `${decryptedValue.slice(0, visibleCharsCount)}${mask}`;
}
}

View file

@ -0,0 +1,120 @@
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
describe('buildEnvVar', () => {
const mockSecretEncryptionService = {
encrypt: jest.fn((value: string) => `encrypted_${value}`),
decrypt: jest.fn((value: string) => value.replace('encrypted_', '')),
} as unknown as SecretEncryptionService;
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty object for empty array', () => {
const result = buildEnvVar([], mockSecretEncryptionService);
expect(result).toEqual({});
});
it('should handle mixed secret and non-secret variables', () => {
const flatVariables: FlatApplicationVariable[] = [
{
id: '1',
key: 'PUBLIC_URL',
value: 'https://example.com',
description: 'Public URL',
isSecret: false,
applicationId: 'app-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: '2',
key: 'API_SECRET',
value: 'encrypted_secret-123',
description: 'API secret',
isSecret: true,
applicationId: 'app-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: '3',
key: 'DEBUG',
value: 'true',
description: 'Debug flag',
isSecret: false,
applicationId: 'app-1',
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.decrypt).toHaveBeenCalledTimes(1);
expect(mockSecretEncryptionService.decrypt).toHaveBeenCalledWith(
'encrypted_secret-123',
);
});
it('should handle null or undefined values', () => {
const flatVariables: FlatApplicationVariable[] = [
{
id: '1',
key: 'NULL_VALUE',
value: null as unknown as string,
description: '',
isSecret: false,
applicationId: 'app-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: '2',
key: 'UNDEFINED_VALUE',
value: undefined as unknown as string,
description: '',
isSecret: false,
applicationId: 'app-1',
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 string,
description: '',
isSecret: false,
applicationId: 'app-1',
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,11 +1,17 @@
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
export const buildEnvVar = (
flatApplicationVariables: FlatApplicationVariable[],
secretEncryptionService: SecretEncryptionService,
): Record<string, string> => {
return flatApplicationVariables.reduce<Record<string, string>>(
(acc, flatApplicationVariable) => {
acc[flatApplicationVariable.key] = flatApplicationVariable.value;
const value = String(flatApplicationVariable.value ?? '');
acc[flatApplicationVariable.key] = flatApplicationVariable.isSecret
? secretEncryptionService.decrypt(value)
: value;
return acc;
},

View file

@ -3,12 +3,12 @@ import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { ConfigCacheService } from 'src/engine/core-modules/twenty-config/cache/config-cache.service';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service';
@Module({})
@ -19,13 +19,13 @@ export class DatabaseConfigModule {
imports: [
TypeOrmModule.forFeature([KeyValuePairEntity]),
ScheduleModule.forRoot(),
SecretEncryptionModule,
],
providers: [
DatabaseConfigDriver,
ConfigCacheService,
ConfigStorageService,
ConfigValueConverterService,
EnvironmentConfigDriver,
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(),

View file

@ -3,11 +3,11 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type DeleteResult, IsNull, type Repository } from 'typeorm';
import * as authUtils from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePairEntity,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
@ -23,15 +23,15 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { TypedReflect } from 'src/utils/typed-reflect';
jest.mock('src/engine/core-modules/auth/auth.util', () => ({
encryptText: jest.fn((text) => `encrypted:${text}`),
decryptText: jest.fn((text) => text.replace('encrypted:', '')),
encryptText: jest.fn((text) => `${text}`),
decryptText: jest.fn((text) => text.replace('', '')),
}));
describe('ConfigStorageService', () => {
let service: ConfigStorageService;
let keyValuePairRepository: Repository<KeyValuePairEntity>;
let configValueConverter: ConfigValueConverterService;
let environmentConfigDriver: EnvironmentConfigDriver;
let secretEncryptionService: SecretEncryptionService;
const createMockKeyValuePair = (
key: string,
@ -79,6 +79,13 @@ describe('ConfigStorageService', () => {
delete: jest.fn(),
},
},
{
provide: SecretEncryptionService,
useValue: {
decrypt: jest.fn((value) => value),
encrypt: jest.fn((value) => value),
},
},
],
}).compile();
@ -89,8 +96,8 @@ describe('ConfigStorageService', () => {
configValueConverter = module.get<ConfigValueConverterService>(
ConfigValueConverterService,
);
environmentConfigDriver = module.get<EnvironmentConfigDriver>(
EnvironmentConfigDriver,
secretEncryptionService = module.get<SecretEncryptionService>(
SecretEncryptionService,
);
jest.clearAllMocks();
@ -167,7 +174,7 @@ describe('ConfigStorageService', () => {
it('should decrypt sensitive string values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const originalValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
const encryptedValue = 'sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
@ -191,10 +198,8 @@ describe('ConfigStorageService', () => {
const result = await service.get(key);
expect(result).toBe(originalValue);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
expect(secretEncryptionService.decrypt).toHaveBeenCalledWith(
encryptedValue,
'test-secret',
);
});
@ -227,43 +232,6 @@ describe('ConfigStorageService', () => {
expect(result).toBe(convertedValue);
});
it('should handle decryption failure in get() by returning original value', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const encryptedValue = 'encrypted:sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(encryptedValue);
// Mock decryption to throw an error
(authUtils.decryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
const result = await service.get(key);
expect(result).toBe(encryptedValue);
expect(authUtils.decryptText).toHaveBeenCalledWith(
encryptedValue,
'test-secret',
);
});
it('should handle findOne errors', async () => {
const key = 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables;
const error = new Error('Database error');
@ -405,7 +373,7 @@ describe('ConfigStorageService', () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
const encryptedValue = 'sensitive-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
@ -431,48 +399,10 @@ describe('ConfigStorageService', () => {
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.encryptText).toHaveBeenCalledWith(
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith(
convertedValue,
'test-secret',
);
});
it('should handle encryption failure in set() by using unconverted value', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'converted-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertAppValueToDbValue as jest.Mock
).mockReturnValue(convertedValue);
// Mock encryption to throw an error
(authUtils.encryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Encryption failed');
});
await service.set(key, value);
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
key: key as string,
value: convertedValue, // Should fall back to unconverted value
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
});
});
describe('delete', () => {
@ -608,7 +538,7 @@ describe('ConfigStorageService', () => {
it('should decrypt sensitive string values in loadAll', async () => {
const configVars: KeyValuePairEntity[] = [
createMockKeyValuePair('SENSITIVE_CONFIG', 'encrypted:sensitive-value'),
createMockKeyValuePair('SENSITIVE_CONFIG', 'sensitive-value'),
createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'),
];
@ -640,10 +570,8 @@ describe('ConfigStorageService', () => {
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
'normal-value',
);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
'encrypted:sensitive-value',
'test-secret',
expect(secretEncryptionService.decrypt).toHaveBeenCalledWith(
'sensitive-value',
);
});
});

View file

@ -4,16 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { type FindOptionsWhere, IsNull, Repository } from 'typeorm';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePairType,
KeyValuePairEntity,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import {
ConfigVariableException,
@ -31,7 +27,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
@InjectRepository(KeyValuePairEntity)
private readonly keyValuePairRepository: Repository<KeyValuePairEntity>,
private readonly configValueConverter: ConfigValueConverterService,
private readonly environmentConfigDriver: EnvironmentConfigDriver,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
private getConfigVariableWhereClause(
@ -45,10 +41,6 @@ export class ConfigStorageService implements ConfigStorageInterface {
};
}
private getAppSecret(): string {
return this.environmentConfigDriver.get('APP_SECRET');
}
private getConfigMetadata<T extends keyof ConfigVariables>(key: T) {
return TypedReflect.getMetadata('config-variables', ConfigVariables)?.[
key as string
@ -77,21 +69,9 @@ export class ConfigStorageService implements ConfigStorageInterface {
return convertedValue;
}
const appSecret = this.getAppSecret();
try {
return isDecrypt
? decryptText(convertedValue, appSecret)
: encryptText(convertedValue, appSecret);
} catch (error) {
this.logger.debug(
`${isDecrypt ? 'Decryption' : 'Encryption'} failed for key ${
key as string
}: ${error.message}. Using original value.`,
);
return convertedValue;
}
return isDecrypt
? this.secretEncryptionService.decrypt(convertedValue)
: this.secretEncryptionService.encrypt(convertedValue);
} catch (error) {
throw new ConfigVariableException(
`Failed to convert value for key ${key as string}: ${error.message}`,

View file

@ -1,13 +1,8 @@
import { type DynamicModule, Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import {
ConfigVariables,
validate,
} from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { DatabaseConfigModule } from 'src/engine/core-modules/twenty-config/drivers/database-config.module';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigurableModuleClass } from 'src/engine/core-modules/twenty-config/twenty-config.module-definition';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -18,22 +13,15 @@ export class TwentyConfigModule extends ConfigurableModuleClass {
const isConfigVariablesInDbEnabled =
process.env.IS_CONFIG_VARIABLES_IN_DB_ENABLED !== 'false';
const imports = [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
}),
...(isConfigVariablesInDbEnabled ? [DatabaseConfigModule.forRoot()] : []),
];
const imports = isConfigVariablesInDbEnabled
? [DatabaseConfigModule.forRoot()]
: [];
return {
module: TwentyConfigModule,
imports,
providers: [
TwentyConfigService,
EnvironmentConfigDriver,
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(),

View file

@ -10,6 +10,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity';
import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
@ -51,6 +52,7 @@ import { FunctionBuildModule } from 'src/engine/metadata-modules/function-build/
SubscriptionsModule,
WorkspaceCacheModule,
TokenModule,
SecretEncryptionModule,
],
providers: [
ServerlessFunctionService,

View file

@ -18,6 +18,7 @@ import { AuditService } from 'src/engine/core-modules/audit/services/audit.servi
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
import { getServerlessFolderOrThrow } from 'src/engine/core-modules/serverless/utils/get-serverless-folder-or-throw.utils';
@ -70,6 +71,7 @@ export class ServerlessFunctionService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async hasServerlessFunctionPublishedVersion(
@ -205,7 +207,7 @@ export class ServerlessFunctionService {
[DEFAULT_API_KEY_NAME]: applicationAccessToken.token,
}
: {}),
...buildEnvVar(flatApplicationVariables),
...buildEnvVar(flatApplicationVariables, this.secretEncryptionService),
};
// We keep that check to build functions