From cc1ee80435f802654574efab219e2a1a0644929b Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Thu, 16 Feb 2023 17:52:53 -0800 Subject: [PATCH] [MD] Support SigV4 as a new auth type of datasource (#3058) * [Multiple DataSource] Add support for SigV4 authentication Signed-off-by: Su (cherry picked from commit de89c527dee3a997bfffc2079dbf97e4f813e54e) --- CHANGELOG.md | 1 + package.json | 2 + src/dev/jest/config.js | 2 + .../data_source/common/data_sources/types.ts | 15 +- .../server/client/client_config.ts | 2 +- .../data_source/server/client/client_pool.ts | 64 +++- .../server/client/configure_client.test.ts | 4 +- .../server/client/configure_client.ts | 199 ++++++----- .../server/client/configure_client_utils.ts | 138 ++++++++ .../data_source/server/client/index.ts | 7 - .../data_source/server/data_source_service.ts | 6 +- .../legacy/configure_legacy_client.test.ts | 4 +- .../server/legacy/configure_legacy_client.ts | 130 ++++--- .../data_source/server/lib/error.test.ts | 4 +- ...ata_source_saved_objects_client_wrapper.ts | 209 +++++++---- .../create_form/create_data_source_form.tsx | 286 +++++++++++---- .../edit_form/edit_data_source_form.tsx | 333 ++++++++++++++++-- .../update_aws_credential_modal/index.ts | 6 + .../update_aws_credential_modal.tsx | 183 ++++++++++ .../validation/datasource_form_validation.ts | 27 ++ .../data_source_management/public/types.ts | 10 +- yarn.lock | 32 +- 22 files changed, 1331 insertions(+), 333 deletions(-) create mode 100644 src/plugins/data_source/server/client/configure_client_utils.ts create mode 100644 src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/index.ts create mode 100644 src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/update_aws_credential_modal.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f18e7b2368..18d6aaef74d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [VisBuilder] Enable persistence for app filter and query without using state containers ([#3100](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3100)) - [Data] Make the newly created configurations get added to beginning of the `aggConfig` array when using `createAggConfig` ([#3160](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3160)) - [Optimizer] Increase the amount of time an optimizer worker is provided to exit before throwing an error ([#3193](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3193)) +- [Multiple DataSource] Add support for SigV4 authentication ([#3058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3058)) ### 🐛 Bug Fixes diff --git a/package.json b/package.json index 6fa2d30ec79d..3dc858939d6f 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "dns-sync": "^0.2.1", "elastic-apm-node": "^3.7.0", "elasticsearch": "^16.7.0", + "http-aws-es": "6.0.0", "execa": "^4.0.2", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", @@ -335,6 +336,7 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", + "@types/http-aws-es": "6.0.2", "angular-aria": "^1.8.0", "angular-mocks": "^1.8.2", "angular-recursion": "^1.0.5", diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 8dd5240f69de..8140017a3f8d 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -52,6 +52,8 @@ export default { moduleNameMapper: { '@elastic/eui$': '/node_modules/@elastic/eui/test-env', '@elastic/eui/lib/(.*)?': '/node_modules/@elastic/eui/test-env/$1', + '@opensearch-project/opensearch-next/aws': + '/node_modules/@opensearch-project/opensearch-next/lib/aws', '^src/plugins/(.*)': '/src/plugins/$1', '^test_utils/(.*)': '/src/test_utils/public/$1', '^fixtures/(.*)': '/src/fixtures/$1', diff --git a/src/plugins/data_source/common/data_sources/types.ts b/src/plugins/data_source/common/data_sources/types.ts index afcf3d662fed..366e5a0f3f55 100644 --- a/src/plugins/data_source/common/data_sources/types.ts +++ b/src/plugins/data_source/common/data_sources/types.ts @@ -11,8 +11,20 @@ export interface DataSourceAttributes extends SavedObjectAttributes { endpoint: string; auth: { type: AuthType; - credentials: UsernamePasswordTypedContent | undefined; + credentials: UsernamePasswordTypedContent | SigV4Content | undefined; }; + lastUpdatedTime?: string; +} + +/** + * Multiple datasource supports authenticating as IAM user, it doesn't support IAM role. + * Because IAM role session requires temporary security credentials through assuming role, + * which makes no sense to store the credentials. + */ +export interface SigV4Content extends SavedObjectAttributes { + accessKey: string; + secretKey: string; + region: string; } export interface UsernamePasswordTypedContent extends SavedObjectAttributes { @@ -23,4 +35,5 @@ export interface UsernamePasswordTypedContent extends SavedObjectAttributes { export enum AuthType { NoAuth = 'no_auth', UsernamePasswordType = 'username_password', + SigV4 = 'sigv4', } diff --git a/src/plugins/data_source/server/client/client_config.ts b/src/plugins/data_source/server/client/client_config.ts index 5973e5a0813f..5de71fe5dc24 100644 --- a/src/plugins/data_source/server/client/client_config.ts +++ b/src/plugins/data_source/server/client/client_config.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ClientOptions } from '@opensearch-project/opensearch'; +import { ClientOptions } from '@opensearch-project/opensearch-next'; import { DataSourcePluginConfigType } from '../../config'; /** diff --git a/src/plugins/data_source/server/client/client_pool.ts b/src/plugins/data_source/server/client/client_pool.ts index 38bd57886920..aa8edad682d0 100644 --- a/src/plugins/data_source/server/client/client_pool.ts +++ b/src/plugins/data_source/server/client/client_pool.ts @@ -7,11 +7,12 @@ import { Client } from '@opensearch-project/opensearch-next'; import { Client as LegacyClient } from 'elasticsearch'; import LRUCache from 'lru-cache'; import { Logger } from 'src/core/server'; +import { AuthType } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; export interface OpenSearchClientPoolSetup { - getClientFromPool: (id: string) => Client | LegacyClient | undefined; - addClientToPool: (endpoint: string, client: Client | LegacyClient) => void; + getClientFromPool: (endpoint: string, authType: AuthType) => Client | LegacyClient | undefined; + addClientToPool: (endpoint: string, authType: AuthType, client: Client | LegacyClient) => void; } /** @@ -21,10 +22,14 @@ export interface OpenSearchClientPoolSetup { * It reuse TPC connections for each OpenSearch endpoint. */ export class OpenSearchClientPool { - // LRU cache + // LRU cache of client // key: data source endpoint - // value: OpenSearch client object | Legacy client object - private cache?: LRUCache; + // value: OpenSearch client | Legacy client + private clientCache?: LRUCache; + // LRU cache of aws clients + // key: endpoint + dataSourceId + lastUpdatedTime together to support update case. + // value: OpenSearch client | Legacy client + private awsClientCache?: LRUCache; private isClosed = false; constructor(private logger: Logger) {} @@ -32,12 +37,13 @@ export class OpenSearchClientPool { public setup(config: DataSourcePluginConfigType): OpenSearchClientPoolSetup { const logger = this.logger; const { size } = config.clientPool; + const MAX_AGE = 15 * 60 * 1000; // by default, TCP connection times out in 15 minutes - this.cache = new LRUCache({ + this.clientCache = new LRUCache({ max: size, - maxAge: 15 * 60 * 1000, // by default, TCP connection times out in 15 minutes + maxAge: MAX_AGE, - async dispose(endpoint, client) { + async dispose(key, client) { try { await client.close(); } catch (error: any) { @@ -50,12 +56,34 @@ export class OpenSearchClientPool { }); this.logger.info(`Created data source client pool of size ${size}`); - const getClientFromPool = (endpoint: string) => { - return this.cache!.get(endpoint); + // aws client specific pool + this.awsClientCache = new LRUCache({ + max: size, + maxAge: MAX_AGE, + + async dispose(key, client) { + try { + await client.close(); + } catch (error: any) { + logger.warn( + `Error closing OpenSearch client when removing from aws client pool: ${error.message}` + ); + } + }, + }); + this.logger.info(`Created data source aws client pool of size ${size}`); + + const getClientFromPool = (key: string, authType: AuthType) => { + const selectedCache = authType === AuthType.SigV4 ? this.awsClientCache : this.clientCache; + + return selectedCache!.get(key); }; - const addClientToPool = (endpoint: string, client: Client | LegacyClient) => { - this.cache!.set(endpoint, client); + const addClientToPool = (key: string, authType: string, client: Client | LegacyClient) => { + const selectedCache = authType === AuthType.SigV4 ? this.awsClientCache : this.clientCache; + if (!selectedCache?.has(key)) { + return selectedCache!.set(key, client); + } }; return { @@ -71,7 +99,15 @@ export class OpenSearchClientPool { if (this.isClosed) { return; } - await Promise.all(this.cache!.values().map((client) => client.close())); - this.isClosed = true; + + try { + await Promise.all([ + ...this.clientCache!.values().map((client) => client.close()), + ...this.awsClientCache!.values().map((client) => client.close()), + ]); + this.isClosed = true; + } catch (error) { + this.logger.error(`Error closing clients in pool. ${error}`); + } } } diff --git a/src/plugins/data_source/server/client/configure_client.test.ts b/src/plugins/data_source/server/client/configure_client.test.ts index 01a39af4713a..0161064b23c5 100644 --- a/src/plugins/data_source/server/client/configure_client.test.ts +++ b/src/plugins/data_source/server/client/configure_client.test.ts @@ -137,7 +137,7 @@ describe('configureClient', () => { configureClient(dataSourceClientParams, clientPoolSetup, config, logger) ).rejects.toThrowError(); - expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).not.toHaveBeenCalled(); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(decodeAndDecryptSpy).toHaveBeenCalledTimes(1); }); @@ -152,7 +152,7 @@ describe('configureClient', () => { configureClient(dataSourceClientParams, clientPoolSetup, config, logger) ).rejects.toThrowError(); - expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).not.toHaveBeenCalled(); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(decodeAndDecryptSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/data_source/server/client/configure_client.ts b/src/plugins/data_source/server/client/configure_client.ts index 140d3850cc74..f203cf6b0027 100644 --- a/src/plugins/data_source/server/client/configure_client.ts +++ b/src/plugins/data_source/server/client/configure_client.ts @@ -3,12 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Client } from '@opensearch-project/opensearch-next'; -import { Logger, SavedObject, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; +import { Client, ClientOptions } from '@opensearch-project/opensearch-next'; +import { Client as LegacyClient } from 'elasticsearch'; +import { Credentials } from 'aws-sdk'; +import { AwsSigv4Signer } from '@opensearch-project/opensearch-next/aws'; +import { Logger } from '../../../../../src/core/server'; import { AuthType, DataSourceAttributes, + SigV4Content, UsernamePasswordTypedContent, } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; @@ -17,6 +20,13 @@ import { createDataSourceError } from '../lib/error'; import { DataSourceClientParams } from '../types'; import { parseClientOptions } from './client_config'; import { OpenSearchClientPoolSetup } from './client_pool'; +import { + getRootClient, + getAWSCredential, + getCredential, + getDataSource, + generateCacheKey, +} from './configure_client_utils'; export const configureClient = async ( { dataSourceId, savedObjects, cryptography }: DataSourceClientParams, @@ -25,13 +35,25 @@ export const configureClient = async ( logger: Logger ): Promise => { try { - const { attributes: dataSource } = await getDataSource(dataSourceId!, savedObjects); - const rootClient = getRootClient(dataSource, config, openSearchClientPoolSetup); - - return await getQueryClient(rootClient, dataSource, cryptography); + const dataSource = await getDataSource(dataSourceId!, savedObjects); + const rootClient = getRootClient( + dataSource, + openSearchClientPoolSetup.getClientFromPool, + dataSourceId + ) as Client; + + return await getQueryClient( + dataSource, + openSearchClientPoolSetup.addClientToPool, + config, + cryptography, + rootClient, + dataSourceId + ); } catch (error: any) { - logger.error(`Failed to get data source client for dataSourceId: [${dataSourceId}]`); - logger.error(error); + logger.error( + `Failed to get data source client for dataSourceId: [${dataSourceId}]. ${error}: ${error.stack}` + ); // Re-throw as DataSourceError throw createDataSourceError(error); } @@ -39,7 +61,7 @@ export const configureClient = async ( export const configureTestClient = async ( { savedObjects, cryptography, dataSourceId }: DataSourceClientParams, - dataSource: DataSourceAttributes, + dataSourceAttr: DataSourceAttributes, openSearchClientPoolSetup: OpenSearchClientPoolSetup, config: DataSourcePluginConfigType, logger: Logger @@ -47,122 +69,93 @@ export const configureTestClient = async ( try { const { auth: { type, credentials }, - } = dataSource; + } = dataSourceAttr; let requireDecryption = false; - const rootClient = getRootClient(dataSource, config, openSearchClientPoolSetup); + const rootClient = getRootClient( + dataSourceAttr, + openSearchClientPoolSetup.getClientFromPool, + dataSourceId + ) as Client; if (type === AuthType.UsernamePasswordType && !credentials?.password && dataSourceId) { - const dataSourceSavedObject = await getDataSource(dataSourceId, savedObjects); - dataSource = dataSourceSavedObject.attributes; + dataSourceAttr = await getDataSource(dataSourceId, savedObjects); requireDecryption = true; } - return getQueryClient(rootClient, dataSource, cryptography, requireDecryption); + return getQueryClient( + dataSourceAttr, + openSearchClientPoolSetup.addClientToPool, + config, + cryptography, + rootClient, + dataSourceId, + requireDecryption + ); } catch (error: any) { - logger.error(`Failed to get test client for dataSource: ${dataSource}`); - logger.error(error); + logger.error(`Failed to get test client. ${error}: ${error.stack}`); // Re-throw as DataSourceError throw createDataSourceError(error); } }; -export const getDataSource = async ( - dataSourceId: string, - savedObjects: SavedObjectsClientContract -): Promise> => { - const dataSource = await savedObjects.get( - DATA_SOURCE_SAVED_OBJECT_TYPE, - dataSourceId - ); - - return dataSource; -}; - -export const getCredential = async ( - dataSource: DataSourceAttributes, - cryptography: CryptographyServiceSetup -): Promise => { - const { endpoint } = dataSource; - - const { username, password } = dataSource.auth.credentials!; - - const { decryptedText, encryptionContext } = await cryptography - .decodeAndDecrypt(password) - .catch((err: any) => { - // Re-throw as DataSourceError - throw createDataSourceError(err); - }); - - if (encryptionContext!.endpoint !== endpoint) { - throw new Error( - 'Data source "endpoint" contaminated. Please delete and create another data source.' - ); - } - - const credential = { - username, - password: decryptedText, - }; - - return credential; -}; - /** * Create a child client object with given auth info. * - * @param rootClient root client for the connection with given data source endpoint. - * @param dataSource data source saved object + * @param rootClient root client for the given data source. + * @param dataSourceAttr data source saved object attributes * @param cryptography cryptography service for password encryption / decryption - * @returns child client. + * @param config data source config + * @param addClientToPool function to add client to client pool + * @param dataSourceId id of data source saved Object + * @param requireDecryption boolean + * @returns Promise of query client */ const getQueryClient = async ( - rootClient: Client, - dataSource: DataSourceAttributes, + dataSourceAttr: DataSourceAttributes, + addClientToPool: (endpoint: string, authType: AuthType, client: Client | LegacyClient) => void, + config: DataSourcePluginConfigType, cryptography?: CryptographyServiceSetup, + rootClient?: Client, + dataSourceId?: string, requireDecryption: boolean = true ): Promise => { - const authType = dataSource.auth.type; - - switch (authType) { + const { + auth: { type }, + endpoint, + } = dataSourceAttr; + const clientOptions = parseClientOptions(config, endpoint); + const cacheKey = generateCacheKey(dataSourceAttr, dataSourceId); + + switch (type) { case AuthType.NoAuth: + if (!rootClient) rootClient = new Client(clientOptions); + addClientToPool(cacheKey, type, rootClient); + return rootClient.child(); case AuthType.UsernamePasswordType: const credential = requireDecryption - ? await getCredential(dataSource, cryptography!) - : (dataSource.auth.credentials as UsernamePasswordTypedContent); + ? await getCredential(dataSourceAttr, cryptography!) + : (dataSourceAttr.auth.credentials as UsernamePasswordTypedContent); + + if (!rootClient) rootClient = new Client(clientOptions); + addClientToPool(cacheKey, type, rootClient); + return getBasicAuthClient(rootClient, credential); - default: - throw Error(`${authType} is not a supported auth type for data source`); - } -}; + case AuthType.SigV4: + const awsCredential = requireDecryption + ? await getAWSCredential(dataSourceAttr, cryptography!) + : (dataSourceAttr.auth.credentials as SigV4Content); -/** - * Gets a root client object of the OpenSearch endpoint. - * Will attempt to get from cache, if cache miss, create a new one and load into cache. - * - * @param dataSourceAttr data source saved objects attributes. - * @param config data source config - * @returns OpenSearch client for the given data source endpoint. - */ -const getRootClient = ( - dataSourceAttr: DataSourceAttributes, - config: DataSourcePluginConfigType, - { getClientFromPool, addClientToPool }: OpenSearchClientPoolSetup -): Client => { - const endpoint = dataSourceAttr.endpoint; - const cachedClient = getClientFromPool(endpoint); - if (cachedClient) { - return cachedClient as Client; - } else { - const clientOptions = parseClientOptions(config, endpoint); + const awsClient = rootClient ? rootClient : getAWSClient(awsCredential, clientOptions); + addClientToPool(cacheKey, type, awsClient); - const client = new Client(clientOptions); - addClientToPool(endpoint, client); + return awsClient; - return client; + default: + throw Error(`${type} is not a supported auth type for data source`); } }; @@ -182,3 +175,21 @@ const getBasicAuthClient = ( headers: { authorization: null }, }); }; + +const getAWSClient = (credential: SigV4Content, clientOptions: ClientOptions): Client => { + const { accessKey, secretKey, region } = credential; + + const credentialProvider = (): Promise => { + return new Promise((resolve) => { + resolve(new Credentials({ accessKeyId: accessKey, secretAccessKey: secretKey })); + }); + }; + + return new Client({ + ...AwsSigv4Signer({ + region, + getCredentials: credentialProvider, + }), + ...clientOptions, + }); +}; diff --git a/src/plugins/data_source/server/client/configure_client_utils.ts b/src/plugins/data_source/server/client/configure_client_utils.ts new file mode 100644 index 000000000000..577e44e73139 --- /dev/null +++ b/src/plugins/data_source/server/client/configure_client_utils.ts @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Client } from '@opensearch-project/opensearch-next'; +import { Client as LegacyClient } from 'elasticsearch'; +import { SavedObjectsClientContract } from '../../../../../src/core/server'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; +import { + DataSourceAttributes, + UsernamePasswordTypedContent, + SigV4Content, + AuthType, +} from '../../common/data_sources'; +import { CryptographyServiceSetup } from '../cryptography_service'; +import { createDataSourceError } from '../lib/error'; + +/** + * Get the root client of datasource from + * client cache. If there's a cache miss, return undefined. + * + * @param dataSourceAttr data source saved objects attributes + * @param dataSourceId id of data source saved Object + * @param addClientToPool function to get client from client pool + * @returns cached OpenSearch client, or undefined if cache miss + */ +export const getRootClient = ( + dataSourceAttr: DataSourceAttributes, + getClientFromPool: (endpoint: string, authType: AuthType) => Client | LegacyClient | undefined, + dataSourceId?: string +): Client | LegacyClient | undefined => { + const { + auth: { type }, + } = dataSourceAttr; + const cacheKey = generateCacheKey(dataSourceAttr, dataSourceId); + const cachedClient = getClientFromPool(cacheKey, type); + + return cachedClient; +}; + +export const getDataSource = async ( + dataSourceId: string, + savedObjects: SavedObjectsClientContract +): Promise => { + const dataSourceSavedObject = await savedObjects.get( + DATA_SOURCE_SAVED_OBJECT_TYPE, + dataSourceId + ); + + const dataSourceAttr = { + ...dataSourceSavedObject.attributes, + lastUpdatedTime: dataSourceSavedObject.updated_at, + }; + + return dataSourceAttr; +}; + +export const getCredential = async ( + dataSource: DataSourceAttributes, + cryptography: CryptographyServiceSetup +): Promise => { + const { endpoint } = dataSource; + const { username, password } = dataSource.auth.credentials as UsernamePasswordTypedContent; + const { decryptedText, encryptionContext } = await cryptography.decodeAndDecrypt(password); + + if (encryptionContext!.endpoint !== endpoint) { + throw new Error( + 'Data source "endpoint" contaminated. Please delete and create another data source.' + ); + } + + const credential = { + username, + password: decryptedText, + }; + + return credential; +}; + +export const getAWSCredential = async ( + dataSource: DataSourceAttributes, + cryptography: CryptographyServiceSetup +): Promise => { + const { endpoint } = dataSource; + const { accessKey, secretKey, region } = dataSource.auth.credentials! as SigV4Content; + + const { + decryptedText: accessKeyText, + encryptionContext: accessKeyEncryptionContext, + } = await cryptography.decodeAndDecrypt(accessKey).catch((err: any) => { + // Re-throw as DataSourceError + throw createDataSourceError(err); + }); + + const { + decryptedText: secretKeyText, + encryptionContext: secretKeyEncryptionContext, + } = await cryptography.decodeAndDecrypt(secretKey).catch((err: any) => { + // Re-throw as DataSourceError + throw createDataSourceError(err); + }); + + if ( + accessKeyEncryptionContext.endpoint !== endpoint || + secretKeyEncryptionContext.endpoint !== endpoint + ) { + throw new Error( + 'Data source "endpoint" contaminated. Please delete and create another data source.' + ); + } + + const credential = { + region, + accessKey: accessKeyText, + secretKey: secretKeyText, + }; + + return credential; +}; + +export const generateCacheKey = (dataSourceAttr: DataSourceAttributes, dataSourceId?: string) => { + const CACHE_KEY_DELIMITER = ','; + const { + auth: { type }, + endpoint, + lastUpdatedTime, + } = dataSourceAttr; + // opensearch-js client doesn't support spawning child with aws sigv4 connection class, + // we are storing/getting the actual client instead of rootClient in/from aws client pool, + // by a key of ",," + const key = + type === AuthType.SigV4 + ? endpoint + CACHE_KEY_DELIMITER + dataSourceId + CACHE_KEY_DELIMITER + lastUpdatedTime + : endpoint; + + return key; +}; diff --git a/src/plugins/data_source/server/client/index.ts b/src/plugins/data_source/server/client/index.ts index faf5dabe4417..9b6824dfa1d5 100644 --- a/src/plugins/data_source/server/client/index.ts +++ b/src/plugins/data_source/server/client/index.ts @@ -4,10 +4,3 @@ */ export { OpenSearchClientPool, OpenSearchClientPoolSetup } from './client_pool'; -export { - configureClient, - getDataSource, - getCredential, - getRootClient, - getValidationClient, -} from './configure_client'; diff --git a/src/plugins/data_source/server/data_source_service.ts b/src/plugins/data_source/server/data_source_service.ts index 798fce739216..e816a25a729f 100644 --- a/src/plugins/data_source/server/data_source_service.ts +++ b/src/plugins/data_source/server/data_source_service.ts @@ -5,11 +5,11 @@ import { LegacyCallAPIOptions, Logger, OpenSearchClient } from '../../../../src/core/server'; import { DataSourcePluginConfigType } from '../config'; -import { configureClient, OpenSearchClientPool } from './client'; +import { OpenSearchClientPool } from './client'; import { configureLegacyClient } from './legacy'; import { DataSourceClientParams } from './types'; import { DataSourceAttributes } from '../common/data_sources'; -import { configureTestClient } from './client/configure_client'; +import { configureTestClient, configureClient } from './client/configure_client'; export interface DataSourceServiceSetup { getDataSourceClient: (params: DataSourceClientParams) => Promise; @@ -49,7 +49,7 @@ export class DataSourceService { return configureClient(params, opensearchClientPoolSetup, config, this.logger); }; - const getTestingClient = ( + const getTestingClient = async ( params: DataSourceClientParams, dataSource: DataSourceAttributes ): Promise => { diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts index bfdf0ce585f0..c047da70b285 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts @@ -168,7 +168,7 @@ describe('configureLegacyClient', () => { configureLegacyClient(dataSourceClientParams, callApiParams, clientPoolSetup, config, logger) ).rejects.toThrowError(); - expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).not.toHaveBeenCalled(); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(decodeAndDecryptSpy).toHaveBeenCalledTimes(1); }); @@ -183,7 +183,7 @@ describe('configureLegacyClient', () => { configureLegacyClient(dataSourceClientParams, callApiParams, clientPoolSetup, config, logger) ).rejects.toThrowError(); - expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).not.toHaveBeenCalled(); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(decodeAndDecryptSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.ts index 137d5b506fb3..6e9522c1ec24 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.ts @@ -3,27 +3,38 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Client } from 'elasticsearch'; +import { Client } from '@opensearch-project/opensearch-next'; +import { Client as LegacyClient, ConfigOptions } from 'elasticsearch'; +import { Credentials } from 'aws-sdk'; import { get } from 'lodash'; +import HttpAmazonESConnector from 'http-aws-es'; +import { Config } from 'aws-sdk'; import { Headers, LegacyAPICaller, LegacyCallAPIOptions, LegacyOpenSearchErrorHelpers, Logger, - SavedObject, } from '../../../../../src/core/server'; import { AuthType, DataSourceAttributes, + SigV4Content, UsernamePasswordTypedContent, } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; import { CryptographyServiceSetup } from '../cryptography_service'; import { DataSourceClientParams, LegacyClientCallAPIParams } from '../types'; -import { OpenSearchClientPoolSetup, getCredential, getDataSource } from '../client'; +import { OpenSearchClientPoolSetup } from '../client'; import { parseClientOptions } from './client_config'; import { createDataSourceError, DataSourceError } from '../lib/error'; +import { + getRootClient, + getAWSCredential, + getCredential, + getDataSource, + generateCacheKey, +} from '../client/configure_client_utils'; export const configureLegacyClient = async ( { dataSourceId, savedObjects, cryptography }: DataSourceClientParams, @@ -33,13 +44,26 @@ export const configureLegacyClient = async ( logger: Logger ) => { try { - const dataSource = await getDataSource(dataSourceId!, savedObjects); - const rootClient = getRootClient(dataSource.attributes, config, openSearchClientPoolSetup); + const dataSourceAttr = await getDataSource(dataSourceId!, savedObjects); + const rootClient = getRootClient( + dataSourceAttr, + openSearchClientPoolSetup.getClientFromPool, + dataSourceId + ) as LegacyClient; - return await getQueryClient(rootClient, dataSource.attributes, cryptography, callApiParams); + return await getQueryClient( + dataSourceAttr, + cryptography, + callApiParams, + openSearchClientPoolSetup.addClientToPool, + config, + rootClient, + dataSourceId + ); } catch (error: any) { - logger.error(`Failed to get data source client for dataSourceId: [${dataSourceId}]`); - logger.error(error); + logger.error( + `Failed to get data source client for dataSourceId: [${dataSourceId}]. ${error}: ${error.stack}` + ); // Re-throw as DataSourceError throw createDataSourceError(error); } @@ -49,57 +73,62 @@ export const configureLegacyClient = async ( * With given auth info, wrap the rootClient and return * * @param rootClient root client for the connection with given data source endpoint. - * @param dataSource data source saved object + * @param dataSourceAttr data source saved object attributes * @param cryptography cryptography service for password encryption / decryption + * @param config data source config + * @param addClientToPool function to add client to client pool + * @param dataSourceId id of data source saved Object * @returns child client. */ const getQueryClient = async ( - rootClient: Client, - dataSource: DataSourceAttributes, + dataSourceAttr: DataSourceAttributes, cryptography: CryptographyServiceSetup, - { endpoint, clientParams, options }: LegacyClientCallAPIParams + { endpoint, clientParams, options }: LegacyClientCallAPIParams, + addClientToPool: (endpoint: string, authType: AuthType, client: Client | LegacyClient) => void, + config: DataSourcePluginConfigType, + rootClient?: LegacyClient, + dataSourceId?: string ) => { - const authType = dataSource.auth.type; + const { + auth: { type }, + endpoint: nodeUrl, + } = dataSourceAttr; + const clientOptions = parseClientOptions(config, nodeUrl); + const cacheKey = generateCacheKey(dataSourceAttr, dataSourceId); - switch (authType) { + switch (type) { case AuthType.NoAuth: + if (!rootClient) rootClient = new LegacyClient(clientOptions); + addClientToPool(cacheKey, type, rootClient); + return await (callAPI.bind(null, rootClient) as LegacyAPICaller)( endpoint, clientParams, options ); + case AuthType.UsernamePasswordType: - const credential = await getCredential(dataSource, cryptography); + const credential = await getCredential(dataSourceAttr, cryptography); + + if (!rootClient) rootClient = new LegacyClient(clientOptions); + addClientToPool(cacheKey, type, rootClient); + return getBasicAuthClient(rootClient, { endpoint, clientParams, options }, credential); - default: - throw Error(`${authType} is not a supported auth type for data source`); - } -}; + case AuthType.SigV4: + const awsCredential = await getAWSCredential(dataSourceAttr, cryptography); -/** - * Gets a root client object of the OpenSearch endpoint. - * Will attempt to get from cache, if cache miss, create a new one and load into cache. - * - * @param dataSourceAttr data source saved objects attributes. - * @param config data source config - * @returns Legacy client for the given data source endpoint. - */ -const getRootClient = ( - dataSourceAttr: DataSourceAttributes, - config: DataSourcePluginConfigType, - { getClientFromPool, addClientToPool }: OpenSearchClientPoolSetup -): Client => { - const endpoint = dataSourceAttr.endpoint; - const cachedClient = getClientFromPool(endpoint); - if (cachedClient) { - return cachedClient as Client; - } else { - const configOptions = parseClientOptions(config, endpoint); - const client = new Client(configOptions); - addClientToPool(endpoint, client); - - return client; + const awsClient = rootClient ? rootClient : getAWSClient(awsCredential, clientOptions); + addClientToPool(cacheKey, type, awsClient); + + return await (callAPI.bind(null, awsClient) as LegacyAPICaller)( + endpoint, + clientParams, + options + ); + + default: + throw Error(`${type} is not a supported auth type for data source`); } }; @@ -113,7 +142,7 @@ const getRootClient = ( * make wrap401Errors default to false, because we don't want login pop-up from browser */ const callAPI = async ( - client: Client, + client: LegacyClient, endpoint: string, clientParams: Record = {}, options: LegacyCallAPIOptions = { wrap401Errors: false } @@ -153,7 +182,7 @@ const callAPI = async ( * @param options - Options that affect the way we call the API and process the result. */ const getBasicAuthClient = async ( - rootClient: Client, + rootClient: LegacyClient, { endpoint, clientParams = {}, options }: LegacyClientCallAPIParams, { username, password }: UsernamePasswordTypedContent ) => { @@ -164,3 +193,16 @@ const getBasicAuthClient = async ( return await (callAPI.bind(null, rootClient) as LegacyAPICaller)(endpoint, clientParams, options); }; + +const getAWSClient = (credential: SigV4Content, clientOptions: ConfigOptions): LegacyClient => { + const { accessKey, secretKey, region } = credential; + const client = new LegacyClient({ + connectionClass: HttpAmazonESConnector, + awsConfig: new Config({ + region, + credentials: new Credentials({ accessKeyId: accessKey, secretAccessKey: secretKey }), + }), + ...clientOptions, + }); + return client; +}; diff --git a/src/plugins/data_source/server/lib/error.test.ts b/src/plugins/data_source/server/lib/error.test.ts index 0705376b63a8..b22e58e39d03 100644 --- a/src/plugins/data_source/server/lib/error.test.ts +++ b/src/plugins/data_source/server/lib/error.test.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ApiResponse } from '@opensearch-project/opensearc-next'; +import { ApiResponse } from '@opensearch-project/opensearch-next'; import { ConnectionError, NoLivingConnectionsError, ResponseError, -} from '@opensearch-project/opensearch/lib/errors'; +} from '@opensearch-project/opensearch-next/lib/errors'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { createDataSourceError, DataSourceError } from './error'; import { errors as LegacyErrors } from 'elasticsearch'; diff --git a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts index 525923f4c577..6b79248d1a94 100644 --- a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts +++ b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts @@ -17,7 +17,12 @@ import { } from 'opensearch-dashboards/server'; import { Logger, SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; -import { AuthType } from '../../common/data_sources'; +import { + AuthType, + DataSourceAttributes, + SigV4Content, + UsernamePasswordTypedContent, +} from '../../common/data_sources'; import { EncryptionContext, CryptographyServiceSetup } from '../cryptography_service'; /** @@ -29,7 +34,7 @@ export class DataSourceSavedObjectsClientWrapper { /** * Describes the factory used to create instances of Saved Objects Client Wrappers - * for data source spcific operations such as credntials encryption + * for data source specific operations such as credentials encryption */ public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { const createWithCredentialsEncryption = async ( @@ -159,13 +164,14 @@ export class DataSourceSavedObjectsClientWrapper { }; case AuthType.UsernamePasswordType: // Signing the data source with endpoint - const encryptionContext = { - endpoint, + return { + ...attributes, + auth: await this.encryptBasicAuthCredential(auth, { endpoint }), }; - + case AuthType.SigV4: return { ...attributes, - auth: await this.encryptCredentials(auth, encryptionContext), + auth: await this.encryptSigV4Credential(auth, { endpoint }), }; default: throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${auth.type}'`); @@ -191,6 +197,8 @@ export class DataSourceSavedObjectsClientWrapper { } const { type, credentials } = auth; + const existingDataSourceAttr = await this.getDataSourceAttributes(wrapperOptions, id, options); + const encryptionContext = await this.getEncryptionContext(existingDataSourceAttr); switch (type) { case AuthType.NoAuth: @@ -204,18 +212,33 @@ export class DataSourceSavedObjectsClientWrapper { }; case AuthType.UsernamePasswordType: if (credentials?.password) { - // Fetch and validate existing signature - const encryptionContext = await this.validateEncryptionContext( - wrapperOptions, - id, - options - ); - + this.validateEncryptionContext(encryptionContext, existingDataSourceAttr); + return { + ...attributes, + auth: await this.encryptBasicAuthCredential(auth, encryptionContext), + }; + } else { + return attributes; + } + case AuthType.SigV4: + this.validateEncryptionContext(encryptionContext, existingDataSourceAttr); + if (credentials?.accessKey && credentials?.secretKey) { return { ...attributes, - auth: await this.encryptCredentials(auth, encryptionContext), + auth: await this.encryptSigV4Credential(auth, encryptionContext), }; } else { + if (credentials?.accessKey) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Failed to update existing data source with auth type ${type}: "credentials.secretKey" missing.` + ); + } + + if (credentials?.secretKey) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Failed to update existing data source with auth type ${type}: "credentials.accessKey" missing.` + ); + } return attributes; } default: @@ -259,7 +282,7 @@ export class DataSourceSavedObjectsClientWrapper { ); } - const { username, password } = credentials; + const { username, password } = credentials as UsernamePasswordTypedContent; if (!username) { throw SavedObjectsErrorHelpers.createBadRequestError( @@ -272,36 +295,45 @@ export class DataSourceSavedObjectsClientWrapper { '"auth.credentials.password" attribute is required' ); } + break; + case AuthType.SigV4: + if (!credentials) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"auth.credentials" attribute is required' + ); + } + const { accessKey, secretKey, region } = credentials as SigV4Content; + + if (!accessKey) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"auth.credentials.accessKey" attribute is required' + ); + } + + if (!secretKey) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"auth.credentials.secretKey" attribute is required' + ); + } + + if (!region) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"auth.credentials.region" attribute is required' + ); + } break; default: throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${type}'`); } } - private async validateEncryptionContext( - wrapperOptions: SavedObjectsClientWrapperOptions, - id: string, - options: SavedObjectsUpdateOptions = {} - ) { - let attributes; - - try { - // Fetch existing data source by id - const savedObject = await wrapperOptions.client.get(DATA_SOURCE_SAVED_OBJECT_TYPE, id, { - namespace: options.namespace, - }); - attributes = savedObject.attributes; - } catch (err: any) { - const errMsg = `Failed to fetch existing data source for dataSourceId [${id}]`; - this.logger.error(errMsg); - this.logger.error(err); - throw SavedObjectsErrorHelpers.decorateBadRequestError(err, errMsg); - } + private async getEncryptionContext(attributes: DataSourceAttributes) { + let encryptionContext: EncryptionContext; if (!attributes) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "attributes" missing. Please delete and create another data source.' + 'Failed to update existing data source: "attributes" missing. Please delete and create another data source.' ); } @@ -309,65 +341,109 @@ export class DataSourceSavedObjectsClientWrapper { if (!endpoint) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "endpoint" missing. Please delete and create another data source.' + 'Failed to update existing data source: "endpoint" missing. Please delete and create another data source.' ); } if (!auth) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "auth" missing. Please delete and create another data source.' + 'Failed to update existing data source: "auth" missing. Please delete and create another data source.' ); } switch (auth.type) { case AuthType.NoAuth: - // Signing the data source with exsiting endpoint - return { - endpoint, - }; + // Signing the data source with existing endpoint + encryptionContext = { endpoint }; + break; case AuthType.UsernamePasswordType: const { credentials } = auth; if (!credentials) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "credentials" missing. Please delete and create another data source.' + 'Failed to update existing data source: "credentials" missing. Please delete and create another data source.' ); } - const { username, password } = credentials; + const { username, password } = credentials as UsernamePasswordTypedContent; if (!username) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "auth.credentials.username" missing. Please delete and create another data source.' + 'Failed to update existing data source: "auth.credentials.username" missing. Please delete and create another data source.' ); } if (!password) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "auth.credentials.username" missing. Please delete and create another data source.' + 'Failed to update existing data source: "auth.credentials.password" missing. Please delete and create another data source.' ); } + encryptionContext = await this.getEncryptionContextFromCipher(password); + break; + case AuthType.SigV4: + const { accessKey, secretKey } = auth.credentials as SigV4Content; + const accessKeyEncryptionContext = await this.getEncryptionContextFromCipher(accessKey); + const secretKeyEncryptionContext = await this.getEncryptionContextFromCipher(secretKey); - const { encryptionContext } = await this.cryptography - .decodeAndDecrypt(password) - .catch((err: any) => { - const errMsg = `Failed to update existing data source for dataSourceId [${id}]: unable to decrypt "auth.credentials.password"`; - this.logger.error(errMsg); - this.logger.error(err); - throw SavedObjectsErrorHelpers.decorateBadRequestError(err, errMsg); - }); - - if (encryptionContext.endpoint !== endpoint) { + if (accessKeyEncryptionContext.endpoint !== secretKeyEncryptionContext.endpoint) { throw SavedObjectsErrorHelpers.createBadRequestError( - 'Update failed due to deprecated data source: "endpoint" contaminated. Please delete and create another data source.' + 'Failed to update existing data source: encryption contexts for "auth.credentials.accessKey" and "auth.credentials.secretKey" must be same. Please delete and create another data source.' ); } - return encryptionContext; + encryptionContext = accessKeyEncryptionContext; + break; default: - throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${type}'`); + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${auth.type}'`); + } + + return encryptionContext; + } + + private async getDataSourceAttributes( + wrapperOptions: SavedObjectsClientWrapperOptions, + id: string, + options: SavedObjectsUpdateOptions = {} + ): Promise { + try { + // Fetch existing data source by id + const savedObject = await wrapperOptions.client.get(DATA_SOURCE_SAVED_OBJECT_TYPE, id, { + namespace: options.namespace, + }); + return savedObject.attributes as DataSourceAttributes; + } catch (err: any) { + const errMsg = `Failed to fetch existing data source for dataSourceId [${id}]`; + this.logger.error(`${errMsg}: ${err} ${err.stack}`); + throw SavedObjectsErrorHelpers.decorateBadRequestError(err, errMsg); + } + } + + private validateEncryptionContext = ( + encryptionContext: EncryptionContext, + dataSource: DataSourceAttributes + ) => { + // validate encryption context + if (encryptionContext.endpoint !== dataSource.endpoint) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Failed to update existing data source: "endpoint" contaminated. Please delete and create another data source.' + ); } + }; + + private async getEncryptionContextFromCipher(cipher: string) { + const { encryptionContext } = await this.cryptography + .decodeAndDecrypt(cipher) + .catch((err: any) => { + const errMsg = `Failed to update existing data source: unable to decrypt auth content`; + this.logger.error(`${errMsg}: ${err} ${err.stack}`); + throw SavedObjectsErrorHelpers.decorateBadRequestError(err, errMsg); + }); + + return encryptionContext; } - private async encryptCredentials(auth: T, encryptionContext: EncryptionContext) { + private async encryptBasicAuthCredential( + auth: T, + encryptionContext: EncryptionContext + ) { const { credentials: { username, password }, } = auth; @@ -380,4 +456,19 @@ export class DataSourceSavedObjectsClientWrapper { }, }; } + + private async encryptSigV4Credential(auth: T, encryptionContext: EncryptionContext) { + const { + credentials: { accessKey, secretKey, region }, + } = auth; + + return { + ...auth, + credentials: { + region, + accessKey: await this.cryptography.encryptAndEncode(accessKey, encryptionContext), + secretKey: await this.cryptography.encryptAndEncode(secretKey, encryptionContext), + }, + }; + } } diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx index 2de6fa497315..6c4cb6a97588 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx @@ -25,6 +25,7 @@ import { DataSourceAttributes, DataSourceManagementContextValue, UsernamePasswordTypedContent, + SigV4Content, } from '../../../../types'; import { Header } from '../header'; import { context as contextType } from '../../../../../../opensearch_dashboards_react/public'; @@ -50,7 +51,7 @@ export interface CreateDataSourceState { endpoint: string; auth: { type: AuthType; - credentials: UsernamePasswordTypedContent; + credentials: UsernamePasswordTypedContent | SigV4Content; }; } @@ -120,20 +121,7 @@ export class CreateDataSourceForm extends React.Component< }; onChangeAuthType = (value: string) => { - const valueToSave = - value === AuthType.UsernamePasswordType ? AuthType.UsernamePasswordType : AuthType.NoAuth; - - const formErrorsByField = { - ...this.state.formErrorsByField, - createCredential: { ...this.state.formErrorsByField.createCredential }, - }; - if (valueToSave === AuthType.NoAuth) { - formErrorsByField.createCredential = { - username: [], - password: [], - }; - } - this.setState({ auth: { ...this.state.auth, type: valueToSave }, formErrorsByField }); + this.setState({ auth: { ...this.state.auth, type: value as AuthType } }); }; onChangeUsername = (e: { target: { value: any } }) => { @@ -180,6 +168,72 @@ export class CreateDataSourceForm extends React.Component< }); }; + onChangeRegion = (e: { target: { value: any } }) => { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, region: e.target.value }, + }, + }); + }; + + validateRegion = () => { + const isValid = !!this.state.auth.credentials.region?.trim().length; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + region: isValid ? [] : [''], + }, + }, + }); + }; + + onChangeAccessKey = (e: { target: { value: any } }) => { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, accessKey: e.target.value }, + }, + }); + }; + + validateAccessKey = () => { + const isValid = !!this.state.auth.credentials.accessKey; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + accessKey: isValid ? [] : [''], + }, + }, + }); + }; + + onChangeSecretKey = (e: { target: { value: any } }) => { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, secretKey: e.target.value }, + }, + }); + }; + + validateSecretKey = () => { + const isValid = !!this.state.auth.credentials.secretKey; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + secretKey: isValid ? [] : [''], + }, + }, + }); + }; + onClickCreateNewDataSource = () => { if (this.isFormValid()) { const formValues: DataSourceAttributes = this.getFormValues(); @@ -201,11 +255,26 @@ export class CreateDataSourceForm extends React.Component< }; getFormValues = (): DataSourceAttributes => { + let credentials = this.state.auth.credentials; + if (this.state.auth.type === AuthType.UsernamePasswordType) { + credentials = { + username: this.state.auth.credentials.username, + password: this.state.auth.credentials.password, + } as UsernamePasswordTypedContent; + } + if (this.state.auth.type === AuthType.SigV4) { + credentials = { + region: this.state.auth.credentials.region, + accessKey: this.state.auth.credentials.accessKey, + secretKey: this.state.auth.credentials.secretKey, + } as SigV4Content; + } + return { title: this.state.title, description: this.state.description, endpoint: this.state.endpoint, - auth: { ...this.state.auth, credentials: { ...this.state.auth.credentials } }, + auth: { ...this.state.auth, credentials }, }; }; @@ -247,55 +316,133 @@ export class CreateDataSourceForm extends React.Component< }; /* Render create new credentials*/ - renderCreateNewCredentialsForm = () => { - return ( - <> - - - - - - - - ); + renderCreateNewCredentialsForm = (type: AuthType) => { + switch (type) { + case AuthType.UsernamePasswordType: + return ( + <> + + + + + + + + ); + case AuthType.SigV4: + return ( + <> + + + + + + + + + + + ); + + default: + break; + } }; renderContent = () => { @@ -419,7 +566,11 @@ export class CreateDataSourceForm extends React.Component< {/* Create New credentials */} {this.state.auth.type === AuthType.UsernamePasswordType - ? this.renderCreateNewCredentialsForm() + ? this.renderCreateNewCredentialsForm(this.state.auth.type) + : null} + + {this.state.auth.type === AuthType.SigV4 + ? this.renderCreateNewCredentialsForm(this.state.auth.type) : null} @@ -430,7 +581,8 @@ export class CreateDataSourceForm extends React.Component< diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx index 561a651edee2..fafca5b724b4 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx @@ -29,6 +29,7 @@ import { credentialSourceOptions, DataSourceAttributes, DataSourceManagementContextValue, + SigV4Content, ToastMessageItem, UsernamePasswordTypedContent, } from '../../../../types'; @@ -40,6 +41,7 @@ import { performDataSourceFormValidation, } from '../../../validation'; import { UpdatePasswordModal } from '../update_password_modal'; +import { UpdateAwsCredentialModal } from '../update_aws_credential_modal'; export interface EditDataSourceProps { existingDataSource: DataSourceAttributes; @@ -56,9 +58,10 @@ export interface EditDataSourceState { endpoint: string; auth: { type: AuthType; - credentials: UsernamePasswordTypedContent; + credentials: UsernamePasswordTypedContent | SigV4Content | undefined; }; showUpdatePasswordModal: boolean; + showUpdateAwsCredentialModal: boolean; showUpdateOptions: boolean; isLoading: boolean; } @@ -81,9 +84,13 @@ export class EditDataSourceForm extends React.Component { - const valueToSave = - value === AuthType.UsernamePasswordType ? AuthType.UsernamePasswordType : AuthType.NoAuth; - - const formErrorsByField = { - ...this.state.formErrorsByField, - createCredential: { ...this.state.formErrorsByField.createCredential }, - }; - if (valueToSave === AuthType.NoAuth) { - formErrorsByField.createCredential = { - username: [], - password: [], - }; - } - this.setState({ auth: { ...this.state.auth, type: valueToSave }, formErrorsByField }, () => { + this.setState({ auth: { ...this.state.auth, type: value as AuthType } }, () => { this.onChangeFormValues(); }); }; @@ -174,7 +176,10 @@ export class EditDataSourceForm extends React.Component { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, region: e.target.value } as SigV4Content, + }, + }); + }; + + validateRegion = () => { + const isValid = !!this.state.auth.credentials.region?.trim().length; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + region: isValid ? [] : [''], + }, + }, + }); + }; + + onChangeAccessKey = (e: { target: { value: any } }) => { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, accessKey: e.target.value } as SigV4Content, + }, + }); + }; + + validateAccessKey = () => { + const isValid = !!this.state.auth.credentials.accessKey; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + accessKey: isValid ? [] : [''], + }, + }, + }); + }; + + onChangeSecretKey = (e: { target: { value: any } }) => { + this.setState({ + auth: { + ...this.state.auth, + credentials: { ...this.state.auth.credentials, secretKey: e.target.value } as SigV4Content, + }, + }); + }; + + validateSecretKey = () => { + const isValid = !!this.state.auth.credentials.secretKey; + this.setState({ + formErrorsByField: { + ...this.state.formErrorsByField, + awsCredential: { + ...this.state.formErrorsByField.awsCredential, + secretKey: isValid ? [] : [''], + }, }, }); }; @@ -221,12 +295,30 @@ export class EditDataSourceForm extends React.Component { + this.setState({ showUpdateAwsCredentialModal: true }); + }; + /* Update password */ updatePassword = async (password: string) => { const { title, description, auth } = this.props.existingDataSource; @@ -315,7 +411,7 @@ export class EditDataSourceForm extends React.Component { + const { title, description, auth } = this.props.existingDataSource; + const updateAttributes: DataSourceAttributes = { + title, + description, + endpoint: undefined, + auth: { + type: auth.type, + credentials: { + region: auth.credentials ? auth.credentials.region : '', + accessKey, + secretKey, + } as SigV4Content, + }, + }; + this.closeAwsCredentialModal(); + + try { + await this.props.handleSubmit(updateAttributes); + this.props.displayToastMessage({ + id: 'dataSourcesManagement.editDataSource.updatePasswordSuccessMsg', + defaultMessage: 'Password updated successfully.', + success: true, + }); + } catch (e) { + this.props.displayToastMessage({ + id: 'dataSourcesManagement.editDataSource.updatePasswordFailMsg', + defaultMessage: 'Updating the stored password failed with some errors.', + }); + } + }; + /* Render methods */ - /* Render Modal for new credential */ + /* Render modal for new credential */ closePasswordModal = () => { this.setState({ showUpdatePasswordModal: false }); }; + closeAwsCredentialModal = () => { + this.setState({ showUpdateAwsCredentialModal: false }); + }; + renderUpdatePasswordModal = () => { return ( <> @@ -367,6 +500,33 @@ export class EditDataSourceForm extends React.Component ); }; + + renderUpdateAwsCredentialModal = () => { + return ( + <> + + { + + } + + + {this.state.showUpdateAwsCredentialModal ? ( + + ) : null} + + ); + }; + /* Render header*/ renderHeader = () => { return ( @@ -575,8 +735,106 @@ export class EditDataSourceForm extends React.Component + {this.renderSelectedAuthType(this.state.auth.type)} + + ); + }; - {this.state.auth.type !== AuthType.NoAuth ? this.renderUsernamePasswordFields() : null} + renderSelectedAuthType = (type: AuthType) => { + switch (type) { + case AuthType.UsernamePasswordType: + return this.renderUsernamePasswordFields(); + case AuthType.SigV4: + return this.renderSigV4ContentFields(); + default: + return null; + } + }; + + renderSigV4ContentFields = () => { + return ( + <> + + + + + + + + + + + {this.props.existingDataSource.auth.type === AuthType.SigV4 + ? this.renderUpdateAwsCredentialModal() + : null} ); }; @@ -600,7 +858,7 @@ export class EditDataSourceForm extends React.Component - {this.props.existingDataSource.auth.type !== AuthType.NoAuth ? ( + {this.props.existingDataSource.auth.type === AuthType.UsernamePasswordType ? ( {this.renderUpdatePasswordModal()} ) : null} @@ -659,12 +917,17 @@ export class EditDataSourceForm extends React.Component void; + closeUpdateAwsCredentialModal: () => void; +} + +export const UpdateAwsCredentialModal = ({ + region, + handleUpdateAwsCredential, + closeUpdateAwsCredentialModal, +}: UpdateAwsCredentialModalProps) => { + /* State Variables */ + const [newAccessKey, setNewAccessKey] = useState(''); + const [isNewAccessKeyValid, setIsNewAccessKeyValid] = useState(true); + + const [newSecretKey, setNewSecretKey] = useState(''); + const [isNewSecretKeyValid, setIsNewSecretKeyValid] = useState(true); + + const onClickUpdateAwsCredential = () => { + if (isFormValid()) { + handleUpdateAwsCredential(newAccessKey, newSecretKey); + } + }; + + const isFormValid = () => { + return !!(newAccessKey && newSecretKey); + }; + + const validateNewAccessKey = () => { + setIsNewAccessKeyValid(!!newAccessKey); + }; + + const validateNewSecretKey = () => { + setIsNewSecretKeyValid(!!newSecretKey); + }; + + const renderUpdateAwsCredentialModal = () => { + return ( + + + +

+ { + + } +

+
+
+ + + + + { + + } + + + + + + {/* Region */} + + + {region} + + + + {/* updated access key */} + + setNewAccessKey(e.target.value)} + onBlur={validateNewAccessKey} + /> + + + {/* updated secret key */} + + setNewSecretKey(e.target.value)} + onBlur={validateNewSecretKey} + /> + + + + + + + { + + } + + + {i18n.translate('dataSourcesManagement.editDataSource.updateStoredAwsCredential', { + defaultMessage: 'Update stored aws credential', + })} + + +
+ ); + }; + + /* Return the modal */ + return
{renderUpdateAwsCredentialModal()}
; +}; diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts index 0e861f1184f3..1abde2d54edb 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts @@ -16,6 +16,11 @@ export interface CreateEditDataSourceValidation { username: string[]; password: string[]; }; + awsCredential: { + region: string[]; + accessKey: string[]; + secretKey: string[]; + }; } export const defaultValidation: CreateEditDataSourceValidation = { @@ -25,6 +30,11 @@ export const defaultValidation: CreateEditDataSourceValidation = { username: [], password: [], }, + awsCredential: { + region: [], + accessKey: [], + secretKey: [], + }, }; export const isTitleValid = ( @@ -84,6 +94,23 @@ export const performDataSourceFormValidation = ( return false; } } + /* AWS SigV4 Content */ + if (formValues?.auth?.type === AuthType.SigV4) { + /* Access key */ + if (!formValues.auth.credentials?.accessKey) { + return false; + } + + /* Secret key */ + if (!formValues.auth.credentials?.secretKey) { + return false; + } + + /* Region */ + if (!formValues.auth.credentials?.region) { + return false; + } + } return true; }; diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index fe52466df1e5..db8b6f1d9a1c 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -53,11 +53,13 @@ export type DataSourceManagementContextValue = OpenSearchDashboardsReactContextV export enum AuthType { NoAuth = 'no_auth', UsernamePasswordType = 'username_password', + SigV4 = 'sigv4', } export const credentialSourceOptions = [ { id: AuthType.NoAuth, label: 'No authentication' }, { id: AuthType.UsernamePasswordType, label: 'Username & Password' }, + { id: AuthType.SigV4, label: 'AWS SigV4' }, ]; export interface DataSourceAttributes extends SavedObjectAttributes { @@ -66,7 +68,7 @@ export interface DataSourceAttributes extends SavedObjectAttributes { endpoint?: string; auth: { type: AuthType; - credentials: UsernamePasswordTypedContent | undefined; + credentials: UsernamePasswordTypedContent | SigV4Content | undefined; }; } @@ -74,3 +76,9 @@ export interface UsernamePasswordTypedContent extends SavedObjectAttributes { username: string; password?: string; } + +export interface SigV4Content extends SavedObjectAttributes { + accessKey: string; + secretKey: string; + region: string; +} diff --git a/yarn.lock b/yarn.lock index 71d20df26a1b..35330d225f8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2861,7 +2861,7 @@ resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.1.0.tgz#ab8109208106b5e764e5a6c92b2ba1c625b73020" integrity sha512-DCg+Ka+uDQ31lJ/UtEXVlaeV3d6t81gifaVWKJy4MYVVgvJttyX/viREy+If7fz+tK/gVxTGMtyrFPnm4gjrVA== -"@types/elasticsearch@^5.0.33": +"@types/elasticsearch@*", "@types/elasticsearch@^5.0.33": version "5.0.40" resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.40.tgz#811f6954088c264173e0a9876b97933250a4da10" integrity sha512-lhnbkC0XorAD7Dt7X+94cXUSHEdDNnEVk/DgFLHgIZQNhixV631Lj4+KpXunTT5rCHyj9RqK3TfO7QrOiwEeUQ== @@ -3072,6 +3072,15 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-aws-es@6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/http-aws-es/-/http-aws-es-6.0.2.tgz#3c608f7da83382bb5a1a35c4f9704296b979ca26" + integrity sha512-VfQ/h+xxdeWP2Sf3BDf2feyzC8duBH5rFPJw2RW5m800fJLkZof/oojn1Atw1jCh4XerjiXRTIyqd5gUQ2iWNw== + dependencies: + "@types/elasticsearch" "*" + "@types/node" "*" + aws-sdk "^2.814.0" + "@types/http-cache-semantics@*": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" @@ -4757,6 +4766,22 @@ aws-sdk@^2.650.0: uuid "8.0.0" xml2js "0.4.19" +aws-sdk@^2.814.0: + version "2.1319.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1319.0.tgz#be1d91fcac13262fa106f0f0a15ee07c023972fd" + integrity sha512-/gPCVsCARitph9FmBTXZmzjX0Br8LwBfu2MTNPGjVCiZkEUSoUWAAigIWkvrjTWOXCI7TSElRwtCzlsVHdv5VA== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.16.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + util "^0.12.4" + uuid "8.0.0" + xml2js "0.4.19" + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -9867,6 +9892,11 @@ htmlparser2@^7.0: domutils "^2.8.0" entities "^3.0.1" +http-aws-es@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/http-aws-es/-/http-aws-es-6.0.0.tgz#1528978d2bee718b8732dcdced0856efa747aeff" + integrity sha512-g+qp7J110/m4aHrR3iit4akAlnW0UljZ6oTq/rCcbsI8KP9x+95vqUtx49M2XQ2JMpwJio3B6gDYx+E8WDxqiA== + http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"