diff --git a/CHANGELOG.md b/CHANGELOG.md index facd1228a115..4849bfcf13b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) +- [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 99df1d808bab..40d643b014fd 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -281,6 +281,14 @@ # 'ff00::/8', # ] +# Optional setting that enables you to specify a path to PEM files for the certificate +# authority for your connected datasources. +#data_source.ssl.certificateAuthorities: [ "/path/to/your/CA.pem" ] + +# To disregard the validity of SSL certificates for connected data sources, change this setting's value to 'none'. +# Possible values include full, certificate and none +#data_source.ssl.verificationMode: full + # Set enabled false to hide authentication method in OpenSearch Dashboards. # If this setting is commented then all 3 options will be available in OpenSearch Dashboards. # Default value will be considered to True. diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts index 50013537b127..30824b486257 100644 --- a/src/plugins/data_source/config.ts +++ b/src/plugins/data_source/config.ts @@ -31,6 +31,15 @@ export const configSchema = schema.object({ defaultValue: new Array(32).fill(0), }), }), + ssl: schema.object({ + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + }), clientPool: schema.object({ size: schema.number({ defaultValue: 5 }), }), diff --git a/src/plugins/data_source/server/client/client_config.test.ts b/src/plugins/data_source/server/client/client_config.test.ts index c6dfff3fe4c6..e6aef818f7de 100644 --- a/src/plugins/data_source/server/client/client_config.test.ts +++ b/src/plugins/data_source/server/client/client_config.test.ts @@ -5,17 +5,20 @@ import { DataSourcePluginConfigType } from '../../config'; import { parseClientOptions } from './client_config'; -const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; -const config = { - enabled: true, - clientPool: { - size: 5, - }, -} as DataSourcePluginConfigType; +const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; describe('parseClientOptions', () => { test('include the ssl client configs as defaults', () => { + const config = { + enabled: true, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( expect.objectContaining({ node: TEST_DATA_SOURCE_ENDPOINT, @@ -26,4 +29,84 @@ describe('parseClientOptions', () => { }) ); }); + + test('test ssl config with verification mode set to none', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'none', + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: false, + ca: [], + }, + }) + ); + }); + + test('test ssl config with verification mode set to certificate', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'certificate', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + ca: ['content-of-some-path'], + }, + }) + ); + expect(parsedConfig.ssl?.checkServerIdentity()).toBeUndefined(); + }); + + test('test ssl config with verification mode set to full', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'full', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: true, + ca: ['content-of-some-path'], + }, + }) + ); + }); }); diff --git a/src/plugins/data_source/server/client/client_config.ts b/src/plugins/data_source/server/client/client_config.ts index 61ffde2be748..f77986810f1b 100644 --- a/src/plugins/data_source/server/client/client_config.ts +++ b/src/plugins/data_source/server/client/client_config.ts @@ -4,7 +4,17 @@ */ import { ClientOptions } from '@opensearch-project/opensearch'; +import { checkServerIdentity } from 'tls'; import { DataSourcePluginConfigType } from '../../config'; +import { readCertificateAuthorities } from '../util/tls_settings_provider'; + +/** @internal */ +type DataSourceSSLConfigOptions = Partial<{ + requestCert: boolean; + rejectUnauthorized: boolean; + checkServerIdentity: typeof checkServerIdentity; + ca: string[]; +}>; /** * Parse the client options from given data source config and endpoint @@ -18,12 +28,40 @@ export function parseClientOptions( endpoint: string, registeredSchema: any[] ): ClientOptions { + const sslConfig: DataSourceSSLConfigOptions = { + requestCert: true, + rejectUnauthorized: true, + }; + + if (config.ssl) { + const verificationMode = config.ssl.verificationMode; + switch (verificationMode) { + case 'none': + sslConfig.rejectUnauthorized = false; + break; + case 'certificate': + sslConfig.rejectUnauthorized = true; + + // by default, NodeJS is checking the server identify + sslConfig.checkServerIdentity = () => undefined; + break; + case 'full': + sslConfig.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + const { certificateAuthorities } = readCertificateAuthorities( + config.ssl?.certificateAuthorities + ); + + sslConfig.ca = certificateAuthorities || []; + } + const clientOptions: ClientOptions = { node: endpoint, - ssl: { - requestCert: true, - rejectUnauthorized: true, - }, + ssl: sslConfig, plugins: registeredSchema, }; diff --git a/src/plugins/data_source/server/legacy/client_config.test.ts b/src/plugins/data_source/server/legacy/client_config.test.ts index a15143ecf69f..67445a686f90 100644 --- a/src/plugins/data_source/server/legacy/client_config.test.ts +++ b/src/plugins/data_source/server/legacy/client_config.test.ts @@ -5,17 +5,20 @@ import { DataSourcePluginConfigType } from '../../config'; import { parseClientOptions } from './client_config'; -const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; -const config = { - enabled: true, - clientPool: { - size: 5, - }, -} as DataSourcePluginConfigType; +const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; describe('parseClientOptions', () => { test('include the ssl client configs as defaults', () => { + const config = { + enabled: true, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( expect.objectContaining({ host: TEST_DATA_SOURCE_ENDPOINT, @@ -25,4 +28,81 @@ describe('parseClientOptions', () => { }) ); }); + + test('test ssl config with verification mode set to none', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'none', + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: false, + ca: [], + }, + }) + ); + }); + + test('test ssl config with verification mode set to certificate', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'certificate', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + ca: ['content-of-some-path'], + }, + }) + ); + expect(parsedConfig.ssl?.checkServerIdentity()).toBeUndefined(); + }); + + test('test ssl config with verification mode set to full', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'full', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: true, + ca: ['content-of-some-path'], + }, + }) + ); + }); }); diff --git a/src/plugins/data_source/server/legacy/client_config.ts b/src/plugins/data_source/server/legacy/client_config.ts index eed052cf245d..a3704d3ec099 100644 --- a/src/plugins/data_source/server/legacy/client_config.ts +++ b/src/plugins/data_source/server/legacy/client_config.ts @@ -4,7 +4,17 @@ */ import { ConfigOptions } from 'elasticsearch'; +import { checkServerIdentity } from 'tls'; import { DataSourcePluginConfigType } from '../../config'; +import { readCertificateAuthorities } from '../util/tls_settings_provider'; + +/** @internal */ +type LegacyDataSourceSSLConfigOptions = Partial<{ + requestCert: boolean; + rejectUnauthorized: boolean; + checkServerIdentity: typeof checkServerIdentity; + ca: string[]; +}>; /** * Parse the client options from given data source config and endpoint @@ -18,11 +28,39 @@ export function parseClientOptions( endpoint: string, registeredSchema: any[] ): ConfigOptions { + const sslConfig: LegacyDataSourceSSLConfigOptions = { + rejectUnauthorized: true, + }; + + if (config.ssl) { + const verificationMode = config.ssl.verificationMode; + switch (verificationMode) { + case 'none': + sslConfig.rejectUnauthorized = false; + break; + case 'certificate': + sslConfig.rejectUnauthorized = true; + + // by default, NodeJS is checking the server identify + sslConfig.checkServerIdentity = () => undefined; + break; + case 'full': + sslConfig.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + const { certificateAuthorities } = readCertificateAuthorities( + config.ssl?.certificateAuthorities + ); + + sslConfig.ca = certificateAuthorities || []; + } + const configOptions: ConfigOptions = { host: endpoint, - ssl: { - rejectUnauthorized: true, - }, + ssl: sslConfig, plugins: registeredSchema, }; diff --git a/src/plugins/data_source/server/util/tls_settings_provider.test.ts b/src/plugins/data_source/server/util/tls_settings_provider.test.ts new file mode 100644 index 000000000000..3458ea8e6ccf --- /dev/null +++ b/src/plugins/data_source/server/util/tls_settings_provider.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { readCertificateAuthorities } from './tls_settings_provider'; + +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; + +describe('readCertificateAuthorities', () => { + test('test readCertificateAuthorities with list of paths', () => { + const ca: string[] = ['some-path']; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: ['content-of-some-path'], + }); + }); + + test('test readCertificateAuthorities with single path', () => { + const ca: string = 'some-path'; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: ['content-of-some-path'], + }); + }); + + test('test readCertificateAuthorities empty list', () => { + const ca: string[] = []; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(0); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: [], + }); + }); + + test('test readCertificateAuthorities undefined', () => { + const ca = undefined; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(0); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: [], + }); + }); +}); diff --git a/src/plugins/data_source/server/util/tls_settings_provider.ts b/src/plugins/data_source/server/util/tls_settings_provider.ts new file mode 100644 index 000000000000..0924041a756d --- /dev/null +++ b/src/plugins/data_source/server/util/tls_settings_provider.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; + +export const readCertificateAuthorities = ( + listOfCertificateAuthorities: string | string[] | undefined +) => { + let certificateAuthorities: string[] | undefined = []; + + const addCertificateAuthorities = (ca: string[]) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + const ca = listOfCertificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + for (const path of paths) { + parsed.push(readFile(path)); + } + addCertificateAuthorities(parsed); + } + + return { + certificateAuthorities, + }; +}; + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +};