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
22 changes: 22 additions & 0 deletions redisinsight/api/src/modules/database/database.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databas
import { ClientMetadataParam } from 'src/common/decorators';
import { ClientMetadata } from 'src/common/models';
import { ModifyDatabaseDto } from 'src/modules/database/dto/modify.database.dto';
import { ExportDatabasesDto } from 'src/modules/database/dto/export.databases.dto';
import { ExportDatabase } from 'src/modules/database/models/export-database';

@ApiTags('Database')
@Controller('databases')
Expand Down Expand Up @@ -207,4 +209,24 @@ export class DatabaseController {
): Promise<void> {
await this.connectionService.connect(clientMetadata);
}

@Post('export')
@ApiEndpoint({
statusCode: 201,
excludeFor: [BuildType.RedisStack],
description: 'Export many databases by ids. With or without passwords and certificates bodies.',
responses: [
{
status: 201,
description: 'Export many databases by ids response',
type: ExportDatabase,
},
],
})
@UsePipes(new ValidationPipe({ transform: true }))
async exportConnections(
@Body() dto: ExportDatabasesDto,
): Promise<ExportDatabase[]> {
return await this.service.export(dto.ids, dto.withSecrets);
}
}
69 changes: 68 additions & 1 deletion redisinsight/api/src/modules/database/database.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { omit, forIn, get, update } from 'lodash';
import { plainToClass } from 'class-transformer';

import { classToClass } from 'src/utils';
import {
mockDatabase, mockDatabaseAnalytics, mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseRepository,
mockRedisService, MockType, mockRedisGeneralInfo, mockRedisConnectionFactory,
mockRedisService, MockType, mockRedisGeneralInfo, mockRedisConnectionFactory, mockDatabaseWithTls, mockDatabaseWithTlsAuth, mockDatabaseWithSshPrivateKey, mockSentinelDatabaseWithTlsAuth,
} from 'src/__mocks__';
import { DatabaseAnalytics } from 'src/modules/database/database.analytics';
import { DatabaseService } from 'src/modules/database/database.service';
Expand All @@ -13,12 +17,21 @@ import { DatabaseInfoProvider } from 'src/modules/database/providers/database-in
import { DatabaseFactory } from 'src/modules/database/providers/database.factory';
import { UpdateDatabaseDto } from 'src/modules/database/dto/update.database.dto';
import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory';
import { ExportDatabase } from './models/export-database';

describe('DatabaseService', () => {
let service: DatabaseService;
let databaseRepository: MockType<DatabaseRepository>;
let redisConnectionFactory: MockType<RedisConnectionFactory>;
let analytics: MockType<DatabaseAnalytics>;
const exportSecurityFields: string[] = [
'password',
'clientCert.key',
'sshOptions.password',
'sshOptions.passphrase',
'sshOptions.privateKey',
'sentinelMaster.password',
]

beforeEach(async () => {
jest.clearAllMocks();
Expand Down Expand Up @@ -158,4 +171,58 @@ describe('DatabaseService', () => {
expect(await service.bulkDelete([mockDatabase.id])).toEqual({ affected: 0 });
});
});

describe('export', () => {
it('should return multiple databases without Standalone secrets', async () => {
databaseRepository.get.mockResolvedValueOnce(mockDatabaseWithTlsAuth);

expect(await service.export([mockDatabaseWithTlsAuth.id], false)).toEqual([classToClass(ExportDatabase,omit(mockDatabaseWithTlsAuth, 'password'))]);
})

it('should return multiple databases without SSH secrets', async () => {
// remove SSH secrets
const mockDatabaseWithSshPrivateKeyTemp = {...mockDatabaseWithSshPrivateKey}
exportSecurityFields.forEach((field) => {
if(get(mockDatabaseWithSshPrivateKeyTemp, field)) {
update(mockDatabaseWithSshPrivateKeyTemp, field, () => null)
}
})

databaseRepository.get.mockResolvedValueOnce(mockDatabaseWithSshPrivateKey);
expect(await service.export([mockDatabaseWithSshPrivateKey.id], false)).toEqual([classToClass(ExportDatabase, mockDatabaseWithSshPrivateKeyTemp)])
})

it('should return multiple databases without Sentinel secrets', async () => {
// remove secrets
const mockSentinelDatabaseWithTlsAuthTemp = {...mockSentinelDatabaseWithTlsAuth}
exportSecurityFields.forEach((field) => {
if(get(mockSentinelDatabaseWithTlsAuthTemp, field)) {
update(mockSentinelDatabaseWithTlsAuthTemp, field, () => null)
}
})

databaseRepository.get.mockResolvedValue(mockSentinelDatabaseWithTlsAuth);

expect(await service.export([mockSentinelDatabaseWithTlsAuth.id], false)).toEqual([classToClass(ExportDatabase, omit(mockSentinelDatabaseWithTlsAuthTemp, 'password'))])
});

it('should return multiple databases with secrets', async () => {
// Standalone
databaseRepository.get.mockResolvedValueOnce(mockDatabaseWithTls);
expect(await service.export([mockDatabaseWithTls.id], true)).toEqual([classToClass(ExportDatabase,mockDatabaseWithTls)]);

// SSH
databaseRepository.get.mockResolvedValueOnce(mockDatabaseWithSshPrivateKey);
expect(await service.export([mockDatabaseWithSshPrivateKey.id], true)).toEqual([classToClass(ExportDatabase, mockDatabaseWithSshPrivateKey)])

// Sentinel
databaseRepository.get.mockResolvedValueOnce(mockSentinelDatabaseWithTlsAuth);
expect(await service.export([mockSentinelDatabaseWithTlsAuth.id], true)).toEqual([classToClass(ExportDatabase, mockSentinelDatabaseWithTlsAuth)])
});

it('should ignore errors', async () => {
databaseRepository.get.mockRejectedValueOnce(new NotFoundException());
expect(await service.export([mockDatabase.id])).toEqual([]);
});
});
});
45 changes: 44 additions & 1 deletion redisinsight/api/src/modules/database/database.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
Injectable, InternalServerErrorException, Logger, NotFoundException,
} from '@nestjs/common';
import { merge, sum } from 'lodash';
import { isEmpty, merge, omit, reject, sum } from 'lodash';
import { Database } from 'src/modules/database/models/database';
import ERROR_MESSAGES from 'src/constants/error-messages';
import { DatabaseRepository } from 'src/modules/database/repositories/database.repository';
Expand All @@ -20,11 +20,21 @@ import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databas
import { ClientContext, Session } from 'src/common/models';
import { ModifyDatabaseDto } from 'src/modules/database/dto/modify.database.dto';
import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory';
import { ExportDatabase } from 'src/modules/database/models/export-database';

@Injectable()
export class DatabaseService {
private logger = new Logger('DatabaseService');

private exportSecurityFields: string[] = [
'password',
'clientCert.key',
'sshOptions.password',
'sshOptions.passphrase',
'sshOptions.privateKey',
'sentinelMaster.password',
]

constructor(
private repository: DatabaseRepository,
private redisService: RedisService,
Expand Down Expand Up @@ -202,4 +212,37 @@ export class DatabaseService {
}))),
};
}

/**
* Export many databases by ids.
* Get full database model. With or without passwords and certificates bodies.
* @param ids
* @param withSecrets
*/
async export(ids: string[], withSecrets = false): Promise<ExportDatabase[]> {
const paths = !withSecrets ? this.exportSecurityFields : []

this.logger.log(`Exporting many database: ${ids}`);

if (!ids.length) {
this.logger.error('Database ids were not provided');
throw new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID);
}

let entities: ExportDatabase[] = reject(
await Promise.all(ids.map(async (id) => {
try {
return await this.get(id);
} catch (e) {
}
})),
isEmpty)

return entities.map((database) =>
classToClass(
ExportDatabase,
omit(database, paths),
{ groups: ['security'] },
))
}
}
24 changes: 24 additions & 0 deletions redisinsight/api/src/modules/database/dto/export.databases.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsBoolean, IsDefined, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';

export class ExportDatabasesDto {
@ApiProperty({
description: 'The unique IDs of the databases requested',
type: String,
isArray: true,
})
@IsDefined()
@IsArray()
@ArrayNotEmpty()
@Type(() => String)
ids: string[] = [];

@ApiPropertyOptional({
description: 'Export passwords and certificate bodies',
type: Boolean,
})
@IsBoolean()
@IsOptional()
withSecrets?: boolean = false;
}
26 changes: 26 additions & 0 deletions redisinsight/api/src/modules/database/models/export-database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { PickType } from '@nestjs/swagger';
import { Database } from './database';

export class ExportDatabase extends PickType(Database, [
'id',
'host',
'port',
'name',
'db',
'username',
'password',
'connectionType',
'nameFromProvider',
'provider',
'lastConnection',
'sentinelMaster',
'nodes',
'modules',
'tls',
'tlsServername',
'verifyServerCert',
'caCert',
'clientCert',
'ssh',
'sshOptions',
] as const) {}
Loading