Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions redisinsight/api/src/__mocks__/database-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@ export const mockDatabaseImportAnalytics = jest.fn(() => ({
sendImportResults: jest.fn(),
sendImportFailed: jest.fn(),
}));

export const mockCertificateImportService = jest.fn(() => {

});
7 changes: 7 additions & 0 deletions redisinsight/api/src/common/utils/certificate-import.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { parse } from 'path';
import { readFileSync } from 'fs';

export const isValidPemCertificate = (cert: string): boolean => cert.startsWith('-----BEGIN CERTIFICATE-----');
export const isValidPemPrivateKey = (cert: string): boolean => cert.startsWith('-----BEGIN PRIVATE KEY-----');
export const getPemBodyFromFileSync = (path: string): string => readFileSync(path).toString('utf8');
export const getCertNameFromFilename = (path: string): string => parse(path).name;
1 change: 1 addition & 0 deletions redisinsight/api/src/common/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './certificate-import.util';
4 changes: 4 additions & 0 deletions redisinsight/api/src/constants/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export default {
INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`,

CA_CERT_EXIST: 'This ca certificate name is already in use.',
INVALID_CA_BODY: 'Invalid CA body',
INVALID_CERTIFICATE_BODY: 'Invalid certificate body',
INVALID_PRIVATE_KEY: 'Invalid private key',
CERTIFICATE_NAME_IS_NOT_DEFINED: 'Certificate name is not defined',
CLIENT_CERT_EXIST: 'This client certificate name is already in use.',
INVALID_CERTIFICATE_ID: 'Invalid certificate id.',
SENTINEL_MASTER_NAME_REQUIRED: 'Sentinel master name must be specified.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { Injectable } from '@nestjs/common';
import { CaCertificate } from 'src/modules/certificate/models/ca-certificate';
import { InjectRepository } from '@nestjs/typeorm';
import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity';
import { Repository } from 'typeorm';
import { EncryptionService } from 'src/modules/encryption/encryption.service';
import { ModelEncryptor } from 'src/modules/encryption/model.encryptor';
import { ClientCertificate } from 'src/modules/certificate/models/client-certificate';
import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity';
import { classToClass } from 'src/utils';
import {
getCertNameFromFilename,
getPemBodyFromFileSync,
isValidPemCertificate,
isValidPemPrivateKey,
} from 'src/common/utils';
import {
InvalidCaCertificateBodyException, InvalidCertificateNameException,
InvalidClientCertificateBodyException, InvalidClientPrivateKeyException,
} from 'src/modules/database-import/exceptions';

@Injectable()
export class CertificateImportService {
private caCertEncryptor: ModelEncryptor;

private clientCertEncryptor: ModelEncryptor;

constructor(
@InjectRepository(CaCertificateEntity)
private readonly caCertRepository: Repository<CaCertificateEntity>,
@InjectRepository(ClientCertificateEntity)
private readonly clientCertRepository: Repository<ClientCertificateEntity>,
private readonly encryptionService: EncryptionService,
) {
this.caCertEncryptor = new ModelEncryptor(encryptionService, ['certificate']);
this.clientCertEncryptor = new ModelEncryptor(encryptionService, ['certificate', 'key']);
}

/**
* Validate data + prepare CA certificate to be imported along with new database
* @param cert
*/
async processCaCertificate(cert: Partial<CaCertificate>): Promise<CaCertificate> {
let toImport: Partial<CaCertificate> = {
certificate: null,
name: cert.name,
};

if (isValidPemCertificate(cert.certificate)) {
toImport.certificate = cert.certificate;
} else {
try {
toImport.certificate = getPemBodyFromFileSync(cert.certificate);
toImport.name = getCertNameFromFilename(cert.certificate);
} catch (e) {
// ignore error
toImport = null;
}
}

if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) {
throw new InvalidCaCertificateBodyException();
}

if (!toImport?.name) {
throw new InvalidCertificateNameException();
}

return this.prepareCaCertificateForImport(toImport);
}

/**
* Use existing certificate if found
* Generate unique name for new certificate
* @param cert
* @private
*/
private async prepareCaCertificateForImport(cert: Partial<CaCertificate>): Promise<CaCertificate> {
const encryptedModel = await this.caCertEncryptor.encryptEntity(cert as CaCertificate);
const existing = await this.caCertRepository.createQueryBuilder('c')
.select('c.id')
.where({ certificate: cert.certificate })
.orWhere({ certificate: encryptedModel.certificate })
.getOne();

if (existing) {
return existing;
}

const name = await CertificateImportService.determineAvailableName(
cert.name,
this.caCertRepository,
);

return classToClass(CaCertificate, {
...cert,
name,
});
}

/**
* Validate data + prepare CA certificate to be imported along with new database
* @param cert
*/
async processClientCertificate(cert: Partial<ClientCertificateEntity>): Promise<ClientCertificate> {
const toImport: Partial<ClientCertificate> = {
certificate: null,
key: null,
name: cert.name,
};

if (isValidPemCertificate(cert.certificate)) {
toImport.certificate = cert.certificate;
} else {
try {
toImport.certificate = getPemBodyFromFileSync(cert.certificate);
toImport.name = getCertNameFromFilename(cert.certificate);
} catch (e) {
// ignore error
toImport.certificate = null;
toImport.name = null;
}
}

if (isValidPemPrivateKey(cert.key)) {
toImport.key = cert.key;
} else {
try {
toImport.key = getPemBodyFromFileSync(cert.key);
} catch (e) {
// ignore error
toImport.key = null;
}
}

if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) {
throw new InvalidClientCertificateBodyException();
}

if (!toImport?.key || !isValidPemPrivateKey(toImport.key)) {
throw new InvalidClientPrivateKeyException();
}

if (!toImport?.name) {
throw new InvalidCertificateNameException();
}

return this.prepareClientCertificateForImport(toImport);
}

/**
* Use existing certificate if found
* Generate unique name for new certificate
* @param cert
* @private
*/
private async prepareClientCertificateForImport(cert: Partial<ClientCertificate>): Promise<ClientCertificate> {
const encryptedModel = await this.clientCertEncryptor.encryptEntity(cert as ClientCertificate);
const existing = await this.clientCertRepository.createQueryBuilder('c')
.select('c.id')
.where({
certificate: cert.certificate,
key: cert.key,
})
.orWhere({
certificate: encryptedModel.certificate,
key: encryptedModel.key,
})
.getOne();

if (existing) {
return existing;
}

const name = await CertificateImportService.determineAvailableName(
cert.name,
this.clientCertRepository,
);

return classToClass(ClientCertificate, {
...cert,
name,
});
}

/**
* Find available name for certificate using such pattern "{N}_{name}"
* @param originalName
* @param repository
*/
static async determineAvailableName(originalName: string, repository: Repository<any>): Promise<string> {
let index = 0;

// temporary solution
// investigate how to make working "regexp" for sqlite
// https://github.com/kriasoft/node-sqlite/issues/55
// https://www.sqlite.org/c3ref/create_function.html
while (true) {
let name = originalName;

if (index) {
name = `${index}_${name}`;
}

if (!await repository
.createQueryBuilder('c')
.where({ name })
.select(['c.id'])
.getOne()) {
return name;
}

index += 1;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { DatabaseImportController } from 'src/modules/database-import/database-import.controller';
import { DatabaseImportService } from 'src/modules/database-import/database-import.service';
import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics';
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';

@Module({
controllers: [DatabaseImportController],
providers: [
DatabaseImportService,
CertificateImportService,
DatabaseImportAnalytics,
],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { pick } from 'lodash';
import { DatabaseImportService } from 'src/modules/database-import/database-import.service';
import {
mockCertificateImportService,
mockDatabase,
mockDatabaseImportAnalytics,
mockDatabaseImportFile,
Expand All @@ -14,9 +15,11 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import {
NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException,
NoDatabaseImportFileProvidedException,
SizeLimitExceededDatabaseImportFileException,
UnableToParseDatabaseImportFileException,
} from 'src/modules/database-import/exceptions';
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';

describe('DatabaseImportService', () => {
let service: DatabaseImportService;
Expand All @@ -36,6 +39,10 @@ describe('DatabaseImportService', () => {
create: jest.fn().mockResolvedValue(mockDatabase),
})),
},
{
provide: CertificateImportService,
useFactory: mockCertificateImportService,
},
{
provide: DatabaseImportAnalytics,
useFactory: mockDatabaseImportAnalytics,
Expand Down Expand Up @@ -156,6 +163,7 @@ describe('DatabaseImportService', () => {
it('should create cluster database', async () => {
await service['createDatabase']({
...mockDatabase,
connectionType: undefined,
cluster: true,
}, 0);

Expand All @@ -166,4 +174,64 @@ describe('DatabaseImportService', () => {
});
});
});

describe('determineConnectionType', () => {
const tcs = [
// common
{ input: {}, output: ConnectionType.NOT_CONNECTED },
// isCluster
{ input: { isCluster: true }, output: ConnectionType.CLUSTER },
{ input: { isCluster: false }, output: ConnectionType.NOT_CONNECTED },
{ input: { isCluster: undefined }, output: ConnectionType.NOT_CONNECTED },
// sentinelMasterName
{ input: { sentinelMasterName: 'some name' }, output: ConnectionType.SENTINEL },
// connectionType
{ input: { connectionType: ConnectionType.STANDALONE }, output: ConnectionType.STANDALONE },
{ input: { connectionType: ConnectionType.CLUSTER }, output: ConnectionType.CLUSTER },
{ input: { connectionType: ConnectionType.SENTINEL }, output: ConnectionType.SENTINEL },
{ input: { connectionType: 'something not supported' }, output: ConnectionType.NOT_CONNECTED },
// type
{ input: { type: 'standalone' }, output: ConnectionType.STANDALONE },
{ input: { type: 'cluster' }, output: ConnectionType.CLUSTER },
{ input: { type: 'sentinel' }, output: ConnectionType.SENTINEL },
{ input: { type: 'something not supported' }, output: ConnectionType.NOT_CONNECTED },
// priority tests
{
input: {
connectionType: ConnectionType.SENTINEL,
type: 'standalone',
isCluster: true,
sentinelMasterName: 'some name',
},
output: ConnectionType.SENTINEL,
},
{
input: {
type: 'standalone',
isCluster: true,
sentinelMasterName: 'some name',
},
output: ConnectionType.STANDALONE,
},
{
input: {
isCluster: true,
sentinelMasterName: 'some name',
},
output: ConnectionType.CLUSTER,
},
{
input: {
sentinelMasterName: 'some name',
},
output: ConnectionType.SENTINEL,
},
];

tcs.forEach((tc) => {
it(`should return ${tc.output} when called with ${JSON.stringify(tc.input)}`, () => {
expect(DatabaseImportService.determineConnectionType(tc.input)).toEqual(tc.output);
});
});
});
});
Loading