Skip to content

Commit f96392b

Browse files
author
Artem
committed
#RI-3728 - Base BE implementation. Import certs by plain values
#RI-3684 - Base BE implementation. Import certs from files
1 parent b774c0f commit f96392b

12 files changed

+364
-4
lines changed
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.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
UnableToParseDatabaseImportFileException,
2222
} from 'src/modules/database-import/exceptions';
2323
import { ValidationException } from 'src/common/exceptions';
24+
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';
2425

2526
@Injectable()
2627
export class DatabaseImportService {
@@ -36,9 +37,17 @@ export class DatabaseImportService {
3637
['port', ['port']],
3738
['db', ['db']],
3839
['isCluster', ['cluster']],
40+
['tls', ['tls', 'ssl']],
41+
['tlsServername', ['tlsServername', 'sni_name', 'sni_server_name']],
42+
['tlsCaName', ['caCert.name']],
43+
['tlsCaCert', ['caCert.certificate', 'sslOptions.ca', 'ssl_ca_cert_path']],
44+
['tlsClientName', ['clientCert.name']],
45+
['tlsClientCert', ['clientCert.certificate', 'sslOptions.cert', 'ssl_local_cert_path']],
46+
['tlsClientKey', ['clientCert.key', 'sslOptions.key', 'ssl_private_key_path']],
3947
];
4048

4149
constructor(
50+
private readonly certificateImportService: CertificateImportService,
4251
private readonly databaseRepository: DatabaseRepository,
4352
private readonly analytics: DatabaseImportAnalytics,
4453
) {}
@@ -115,6 +124,8 @@ export class DatabaseImportService {
115124
*/
116125
private async createDatabase(item: any, index: number): Promise<DatabaseImportResult> {
117126
try {
127+
let status = DatabaseImportStatus.Success;
128+
const errors = [];
118129
const data: any = {};
119130

120131
this.fieldsMapSchema.forEach(([field, paths]) => {
@@ -140,6 +151,33 @@ export class DatabaseImportService {
140151
data.connectionType = ConnectionType.STANDALONE;
141152
}
142153

154+
if (data?.tlsCaCert) {
155+
try {
156+
data.tls = true;
157+
data.caCert = await this.certificateImportService.processCaCertificate({
158+
certificate: data.tlsCaCert,
159+
name: data?.tlsCaName,
160+
});
161+
} catch (e) {
162+
status = DatabaseImportStatus.Partial;
163+
errors.push(e);
164+
}
165+
}
166+
167+
if (data?.tlsClientCert || data?.tlsClientKey) {
168+
try {
169+
data.tls = true;
170+
data.clientCert = await this.certificateImportService.processClientCertificate({
171+
certificate: data.tlsClientCert,
172+
key: data.tlsClientKey,
173+
name: data?.tlsClientName,
174+
});
175+
} catch (e) {
176+
status = DatabaseImportStatus.Partial;
177+
errors.push(e);
178+
}
179+
}
180+
143181
const dto = plainToClass(
144182
ImportDatabaseDto,
145183
// additionally replace empty strings ("") with null
@@ -148,6 +186,9 @@ export class DatabaseImportService {
148186
acc[key] = data[key] === '' ? null : data[key];
149187
return acc;
150188
}, {}),
189+
{
190+
groups: ['security'],
191+
},
151192
);
152193

153194
await this.validator.validateOrReject(dto, {
@@ -160,9 +201,10 @@ export class DatabaseImportService {
160201

161202
return {
162203
index,
163-
status: DatabaseImportStatus.Success,
204+
status,
164205
host: database.host,
165206
port: database.port,
207+
errors: errors?.length ? errors : undefined,
166208
};
167209
} catch (e) {
168210
let errors = [e];
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import { PickType } from '@nestjs/swagger';
1+
import { ApiPropertyOptional, getSchemaPath, PickType } from '@nestjs/swagger';
22
import { Database } from 'src/modules/database/models/database';
33
import { Expose, Type } from 'class-transformer';
44
import {
5-
IsInt, IsNotEmpty, Max, Min,
5+
IsInt, IsNotEmpty, IsNotEmptyObject, IsOptional, Max, Min, ValidateNested,
66
} from 'class-validator';
7+
import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer';
8+
import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto';
9+
import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto';
10+
import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto';
11+
import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer';
12+
import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto';
713

814
export class ImportDatabaseDto extends PickType(Database, [
915
'host', 'port', 'name', 'db', 'username', 'password',
10-
'connectionType',
16+
'connectionType', 'tls', 'verifyServerCert',
1117
] as const) {
1218
@Expose()
1319
@IsNotEmpty()
@@ -16,4 +22,26 @@ export class ImportDatabaseDto extends PickType(Database, [
1622
@Min(0)
1723
@Max(65535)
1824
port: number;
25+
26+
@Expose()
27+
@IsOptional()
28+
@IsNotEmptyObject()
29+
@Type(caCertTransformer)
30+
@ValidateNested()
31+
caCert?: CreateCaCertificateDto | UseCaCertificateDto;
32+
33+
@ApiPropertyOptional({
34+
description: 'Client Certificate',
35+
oneOf: [
36+
{ $ref: getSchemaPath(CreateClientCertificateDto) },
37+
{ $ref: getSchemaPath(UseCaCertificateDto) },
38+
],
39+
})
40+
@Expose()
41+
@IsOptional()
42+
@IsNotEmptyObject()
43+
@Type(clientCertTransformer)
44+
@ValidateNested()
45+
clientCert?: CreateClientCertificateDto | UseClientCertificateDto;
46+
1947
}

0 commit comments

Comments
 (0)