Skip to content

Commit fdbce72

Browse files
author
arthosofteq
authored
Merge pull request #1497 from RedisInsight/be/feature/RI-3728-import_certificates
#RI-3728 - Base BE implementation. Import certs by plain values
2 parents b774c0f + b02beea commit fdbce72

20 files changed

+547
-13
lines changed

redisinsight/api/src/__mocks__/database-import.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,7 @@ export const mockDatabaseImportAnalytics = jest.fn(() => ({
6262
sendImportResults: jest.fn(),
6363
sendImportFailed: jest.fn(),
6464
}));
65+
66+
export const mockCertificateImportService = jest.fn(() => {
67+
68+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { parse } from 'path';
2+
import { readFileSync } from 'fs';
3+
4+
export const isValidPemCertificate = (cert: string): boolean => cert.startsWith('-----BEGIN CERTIFICATE-----');
5+
export const isValidPemPrivateKey = (cert: string): boolean => cert.startsWith('-----BEGIN PRIVATE KEY-----');
6+
export const getPemBodyFromFileSync = (path: string): string => readFileSync(path).toString('utf8');
7+
export const getCertNameFromFilename = (path: string): string => parse(path).name;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './certificate-import.util';

redisinsight/api/src/constants/error-messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export default {
1717
INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`,
1818

1919
CA_CERT_EXIST: 'This ca certificate name is already in use.',
20+
INVALID_CA_BODY: 'Invalid CA body',
21+
INVALID_CERTIFICATE_BODY: 'Invalid certificate body',
22+
INVALID_PRIVATE_KEY: 'Invalid private key',
23+
CERTIFICATE_NAME_IS_NOT_DEFINED: 'Certificate name is not defined',
2024
CLIENT_CERT_EXIST: 'This client certificate name is already in use.',
2125
INVALID_CERTIFICATE_ID: 'Invalid certificate id.',
2226
SENTINEL_MASTER_NAME_REQUIRED: 'Sentinel master name must be specified.',
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { CaCertificate } from 'src/modules/certificate/models/ca-certificate';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity';
5+
import { Repository } from 'typeorm';
6+
import { EncryptionService } from 'src/modules/encryption/encryption.service';
7+
import { ModelEncryptor } from 'src/modules/encryption/model.encryptor';
8+
import { ClientCertificate } from 'src/modules/certificate/models/client-certificate';
9+
import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity';
10+
import { classToClass } from 'src/utils';
11+
import {
12+
getCertNameFromFilename,
13+
getPemBodyFromFileSync,
14+
isValidPemCertificate,
15+
isValidPemPrivateKey,
16+
} from 'src/common/utils';
17+
import {
18+
InvalidCaCertificateBodyException, InvalidCertificateNameException,
19+
InvalidClientCertificateBodyException, InvalidClientPrivateKeyException,
20+
} from 'src/modules/database-import/exceptions';
21+
22+
@Injectable()
23+
export class CertificateImportService {
24+
private caCertEncryptor: ModelEncryptor;
25+
26+
private clientCertEncryptor: ModelEncryptor;
27+
28+
constructor(
29+
@InjectRepository(CaCertificateEntity)
30+
private readonly caCertRepository: Repository<CaCertificateEntity>,
31+
@InjectRepository(ClientCertificateEntity)
32+
private readonly clientCertRepository: Repository<ClientCertificateEntity>,
33+
private readonly encryptionService: EncryptionService,
34+
) {
35+
this.caCertEncryptor = new ModelEncryptor(encryptionService, ['certificate']);
36+
this.clientCertEncryptor = new ModelEncryptor(encryptionService, ['certificate', 'key']);
37+
}
38+
39+
/**
40+
* Validate data + prepare CA certificate to be imported along with new database
41+
* @param cert
42+
*/
43+
async processCaCertificate(cert: Partial<CaCertificate>): Promise<CaCertificate> {
44+
let toImport: Partial<CaCertificate> = {
45+
certificate: null,
46+
name: cert.name,
47+
};
48+
49+
if (isValidPemCertificate(cert.certificate)) {
50+
toImport.certificate = cert.certificate;
51+
} else {
52+
try {
53+
toImport.certificate = getPemBodyFromFileSync(cert.certificate);
54+
toImport.name = getCertNameFromFilename(cert.certificate);
55+
} catch (e) {
56+
// ignore error
57+
toImport = null;
58+
}
59+
}
60+
61+
if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) {
62+
throw new InvalidCaCertificateBodyException();
63+
}
64+
65+
if (!toImport?.name) {
66+
throw new InvalidCertificateNameException();
67+
}
68+
69+
return this.prepareCaCertificateForImport(toImport);
70+
}
71+
72+
/**
73+
* Use existing certificate if found
74+
* Generate unique name for new certificate
75+
* @param cert
76+
* @private
77+
*/
78+
private async prepareCaCertificateForImport(cert: Partial<CaCertificate>): Promise<CaCertificate> {
79+
const encryptedModel = await this.caCertEncryptor.encryptEntity(cert as CaCertificate);
80+
const existing = await this.caCertRepository.createQueryBuilder('c')
81+
.select('c.id')
82+
.where({ certificate: cert.certificate })
83+
.orWhere({ certificate: encryptedModel.certificate })
84+
.getOne();
85+
86+
if (existing) {
87+
return existing;
88+
}
89+
90+
const name = await CertificateImportService.determineAvailableName(
91+
cert.name,
92+
this.caCertRepository,
93+
);
94+
95+
return classToClass(CaCertificate, {
96+
...cert,
97+
name,
98+
});
99+
}
100+
101+
/**
102+
* Validate data + prepare CA certificate to be imported along with new database
103+
* @param cert
104+
*/
105+
async processClientCertificate(cert: Partial<ClientCertificateEntity>): Promise<ClientCertificate> {
106+
const toImport: Partial<ClientCertificate> = {
107+
certificate: null,
108+
key: null,
109+
name: cert.name,
110+
};
111+
112+
if (isValidPemCertificate(cert.certificate)) {
113+
toImport.certificate = cert.certificate;
114+
} else {
115+
try {
116+
toImport.certificate = getPemBodyFromFileSync(cert.certificate);
117+
toImport.name = getCertNameFromFilename(cert.certificate);
118+
} catch (e) {
119+
// ignore error
120+
toImport.certificate = null;
121+
toImport.name = null;
122+
}
123+
}
124+
125+
if (isValidPemPrivateKey(cert.key)) {
126+
toImport.key = cert.key;
127+
} else {
128+
try {
129+
toImport.key = getPemBodyFromFileSync(cert.key);
130+
} catch (e) {
131+
// ignore error
132+
toImport.key = null;
133+
}
134+
}
135+
136+
if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) {
137+
throw new InvalidClientCertificateBodyException();
138+
}
139+
140+
if (!toImport?.key || !isValidPemPrivateKey(toImport.key)) {
141+
throw new InvalidClientPrivateKeyException();
142+
}
143+
144+
if (!toImport?.name) {
145+
throw new InvalidCertificateNameException();
146+
}
147+
148+
return this.prepareClientCertificateForImport(toImport);
149+
}
150+
151+
/**
152+
* Use existing certificate if found
153+
* Generate unique name for new certificate
154+
* @param cert
155+
* @private
156+
*/
157+
private async prepareClientCertificateForImport(cert: Partial<ClientCertificate>): Promise<ClientCertificate> {
158+
const encryptedModel = await this.clientCertEncryptor.encryptEntity(cert as ClientCertificate);
159+
const existing = await this.clientCertRepository.createQueryBuilder('c')
160+
.select('c.id')
161+
.where({
162+
certificate: cert.certificate,
163+
key: cert.key,
164+
})
165+
.orWhere({
166+
certificate: encryptedModel.certificate,
167+
key: encryptedModel.key,
168+
})
169+
.getOne();
170+
171+
if (existing) {
172+
return existing;
173+
}
174+
175+
const name = await CertificateImportService.determineAvailableName(
176+
cert.name,
177+
this.clientCertRepository,
178+
);
179+
180+
return classToClass(ClientCertificate, {
181+
...cert,
182+
name,
183+
});
184+
}
185+
186+
/**
187+
* Find available name for certificate using such pattern "{N}_{name}"
188+
* @param originalName
189+
* @param repository
190+
*/
191+
static async determineAvailableName(originalName: string, repository: Repository<any>): Promise<string> {
192+
let index = 0;
193+
194+
// temporary solution
195+
// investigate how to make working "regexp" for sqlite
196+
// https://github.com/kriasoft/node-sqlite/issues/55
197+
// https://www.sqlite.org/c3ref/create_function.html
198+
while (true) {
199+
let name = originalName;
200+
201+
if (index) {
202+
name = `${index}_${name}`;
203+
}
204+
205+
if (!await repository
206+
.createQueryBuilder('c')
207+
.where({ name })
208+
.select(['c.id'])
209+
.getOne()) {
210+
return name;
211+
}
212+
213+
index += 1;
214+
}
215+
}
216+
}

redisinsight/api/src/modules/database-import/database-import.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
22
import { DatabaseImportController } from 'src/modules/database-import/database-import.controller';
33
import { DatabaseImportService } from 'src/modules/database-import/database-import.service';
44
import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics';
5+
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';
56

67
@Module({
78
controllers: [DatabaseImportController],
89
providers: [
910
DatabaseImportService,
11+
CertificateImportService,
1012
DatabaseImportAnalytics,
1113
],
1214
})

redisinsight/api/src/modules/database-import/database-import.service.spec.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { pick } from 'lodash';
22
import { DatabaseImportService } from 'src/modules/database-import/database-import.service';
33
import {
4+
mockCertificateImportService,
45
mockDatabase,
56
mockDatabaseImportAnalytics,
67
mockDatabaseImportFile,
@@ -14,9 +15,11 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity';
1415
import { BadRequestException, ForbiddenException } from '@nestjs/common';
1516
import { ValidationError } from 'class-validator';
1617
import {
17-
NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException,
18+
NoDatabaseImportFileProvidedException,
19+
SizeLimitExceededDatabaseImportFileException,
1820
UnableToParseDatabaseImportFileException,
1921
} from 'src/modules/database-import/exceptions';
22+
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';
2023

2124
describe('DatabaseImportService', () => {
2225
let service: DatabaseImportService;
@@ -36,6 +39,10 @@ describe('DatabaseImportService', () => {
3639
create: jest.fn().mockResolvedValue(mockDatabase),
3740
})),
3841
},
42+
{
43+
provide: CertificateImportService,
44+
useFactory: mockCertificateImportService,
45+
},
3946
{
4047
provide: DatabaseImportAnalytics,
4148
useFactory: mockDatabaseImportAnalytics,
@@ -154,6 +161,7 @@ describe('DatabaseImportService', () => {
154161
it('should create cluster database', async () => {
155162
await service['createDatabase']({
156163
...mockDatabase,
164+
connectionType: undefined,
157165
cluster: true,
158166
}, 0);
159167

@@ -163,4 +171,64 @@ describe('DatabaseImportService', () => {
163171
});
164172
});
165173
});
174+
175+
describe('determineConnectionType', () => {
176+
const tcs = [
177+
// common
178+
{ input: {}, output: ConnectionType.NOT_CONNECTED },
179+
// isCluster
180+
{ input: { isCluster: true }, output: ConnectionType.CLUSTER },
181+
{ input: { isCluster: false }, output: ConnectionType.NOT_CONNECTED },
182+
{ input: { isCluster: undefined }, output: ConnectionType.NOT_CONNECTED },
183+
// sentinelMasterName
184+
{ input: { sentinelMasterName: 'some name' }, output: ConnectionType.SENTINEL },
185+
// connectionType
186+
{ input: { connectionType: ConnectionType.STANDALONE }, output: ConnectionType.STANDALONE },
187+
{ input: { connectionType: ConnectionType.CLUSTER }, output: ConnectionType.CLUSTER },
188+
{ input: { connectionType: ConnectionType.SENTINEL }, output: ConnectionType.SENTINEL },
189+
{ input: { connectionType: 'something not supported' }, output: ConnectionType.NOT_CONNECTED },
190+
// type
191+
{ input: { type: 'standalone' }, output: ConnectionType.STANDALONE },
192+
{ input: { type: 'cluster' }, output: ConnectionType.CLUSTER },
193+
{ input: { type: 'sentinel' }, output: ConnectionType.SENTINEL },
194+
{ input: { type: 'something not supported' }, output: ConnectionType.NOT_CONNECTED },
195+
// priority tests
196+
{
197+
input: {
198+
connectionType: ConnectionType.SENTINEL,
199+
type: 'standalone',
200+
isCluster: true,
201+
sentinelMasterName: 'some name',
202+
},
203+
output: ConnectionType.SENTINEL,
204+
},
205+
{
206+
input: {
207+
type: 'standalone',
208+
isCluster: true,
209+
sentinelMasterName: 'some name',
210+
},
211+
output: ConnectionType.STANDALONE,
212+
},
213+
{
214+
input: {
215+
isCluster: true,
216+
sentinelMasterName: 'some name',
217+
},
218+
output: ConnectionType.CLUSTER,
219+
},
220+
{
221+
input: {
222+
sentinelMasterName: 'some name',
223+
},
224+
output: ConnectionType.SENTINEL,
225+
},
226+
];
227+
228+
tcs.forEach((tc) => {
229+
it(`should return ${tc.output} when called with ${JSON.stringify(tc.input)}`, () => {
230+
expect(DatabaseImportService.determineConnectionType(tc.input)).toEqual(tc.output);
231+
});
232+
});
233+
});
166234
});

0 commit comments

Comments
 (0)