mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
a913ac7452
commit
7e3d9cd85a
19 changed files with 865 additions and 156 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const SECRET_APPLICATION_VARIABLE_MASK = '********';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue