diff --git a/.eslintrc.js b/.eslintrc.js index 2381a3071913..90e789d38e58 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -459,7 +459,7 @@ module.exports = { * Files that run BEFORE node version check */ { - files: ['scripts/**/*.js', 'src/setup_node_env/**/*.js'], + files: ['scripts/**/*.js', 'src/setup_node_env/**/!(*.test).js'], rules: { 'import/no-commonjs': 'off', 'prefer-object-spread/prefer-object-spread': 'off', diff --git a/.github/workflows/add_untriaged_label.yml b/.github/workflows/add-untriaged.yml similarity index 96% rename from .github/workflows/add_untriaged_label.yml rename to .github/workflows/add-untriaged.yml index 15b9a5565125..9dcc7020d245 100644 --- a/.github/workflows/add_untriaged_label.yml +++ b/.github/workflows/add-untriaged.yml @@ -1,19 +1,19 @@ -name: Apply 'untriaged' label during issue lifecycle - -on: - issues: - types: [opened, reopened, transferred] - -jobs: - apply-label: - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ['untriaged'] - }) +name: Apply 'untriaged' label during issue lifecycle + +on: + issues: + types: [opened, reopened, transferred] + +jobs: + apply-label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v6 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['untriaged'] + }) diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index f2e1f5759d52..7abc1e639e7d 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -451,7 +451,7 @@ jobs: working-directory: ./artifacts strategy: matrix: - version: [ osd-2.0.0, osd-2.1.0, osd-2.2.0, osd-2.3.0, osd-2.4.0 ] + version: [ osd-2.0.0, osd-2.1.0, osd-2.2.0, osd-2.3.0, osd-2.4.0, osd-2.5.0 ] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index c06ea5f0030c..24a94efa75d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [CVE-2022-35256] Bumps node version from 14.20.0 to 14.20.1 [#3166](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3166)) - [CVE-2022-46175] Bumps json5 version from 1.0.1 and 2.2.1 to 1.0.2 and 2.2.3 ([#3201](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3201)) - [CVE-2022-25860] Bumps simple-git from 3.15.1 to 3.16.0 ([#3345](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3345)) +- [Security] Bumps hapi/statehood to 7.0.4 ([#3411](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3411)) +- [CVE-2023-25166] Bump formula to 3.0.1 ([#3416](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3416)) ### 📈 Features/Enhancements @@ -58,6 +60,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Optimizer] Increase timeout waiting for the exiting of an optimizer worker ([#3193](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3193)) - [Data] Update `createAggConfig` so that newly created configs can be added to beginning of `aggConfig` array ([#3160](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3160)) - Add disablePrototypePoisoningProtection configuration to prevent JS client from erroring when cluster utilizes JS reserved words ([#2992](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2992)) +- [Multiple DataSource] Add support for SigV4 authentication ([#3058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3058)) ### 🐛 Bug Fixes @@ -93,6 +96,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [VisBuilder] Fixes pipeline aggs ([#3137](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3137)) - [Region Maps] Fixes bug that prevents selected join field to be used ([#3213](Fix bug that prevents selected join field to be used)) - [Multi DataSource]Update test connection button text([#3247](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3247)) +- [Region Maps] Add ui setting to configure custom vector map's size parameter([#3399](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3399)) +- [Search Telemetry] Fixes search telemetry's observable object that won't be GC-ed([#3390](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3390)) ### 🚞 Infrastructure @@ -115,6 +120,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Doc] Add readme for global query persistence ([#3001](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3001)) - Updates NOTICE file, adds validation to GitHub CI ([#3051](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3051)) - [Doc] Add current plugin persistence implementation readme ([#3081](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3081)) +- Correct copyright date range of NOTICE file and notice generator ([#3308](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3308)) ### 🛠 Maintenance @@ -123,6 +129,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Remove `github-checks-reporter`, an unused dependency ([#3126](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3126)) - Upgrade `vega-lite` dependency to ^5.6.0 ([#3076](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3076)) - Bumps `re2` and `supertest` ([3018](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3018)) +- Bump `vega-tooltip` version from ^0.24.2 to ^0.30.0 ([#3358](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3358)) ### 🪛 Refactoring @@ -141,6 +148,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Correct the linting logic for `no-restricted-path` to ignore trailing slashes ([#3020](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3020)) - [Tests] Bumps `chromedriver` to v107 ([#3017](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3017)) - [Vis Builder] Adds field unit tests ([#3211](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3211)) +- [BWC Tests] Add BWC tests for 2.6.0 ([#3356](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3356)) +- Prevent primitive linting limitations from being applied to unit tests found under `src/setup_node_env` ([#3403](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3403)) ## [2.x] @@ -160,6 +169,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [CVE-2022-37599] Bump loader-utils to 2.0.4 ([#3031](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3031)) - [CVE-2022-37603] Bump loader-utils to 2.0.4 ([#3031](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3031)) - [WS-2021-0638][security] bump mocha to 10.1.0 ([#2711](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2711)) +- [CVE-2022-25881] Resolve http-cache-semantics to 4.1.1 ([#3409](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3409)) ### 📈 Features/Enhancements diff --git a/NOTICE.txt b/NOTICE.txt index 5962cc847b61..34da5d0a6f44 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -3,7 +3,7 @@ Copyright OpenSearch Contributors This product includes software, including Kibana source code, developed by Elasticsearch (http://www.elastic.co). -Copyright 2009-2018 Elasticsearch B.V. +Copyright 2009-2021 Elasticsearch B.V. This product includes software developed by The Apache Software Foundation (http://www.apache.org/) diff --git a/bwctest.sh b/bwctest.sh index 4749b99f1012..1b44d15b64cf 100755 --- a/bwctest.sh +++ b/bwctest.sh @@ -13,7 +13,7 @@ set -e -DEFAULT_VERSIONS="osd-2.0.0,osd-2.1.0,osd-2.2.0,osd-2.3.0,osd-2.4.0" +DEFAULT_VERSIONS="osd-2.0.0,osd-2.1.0,osd-2.2.0,osd-2.3.0,osd-2.4.0,osd-2.5.0" function usage() { echo "" diff --git a/cypress/test-data/with-security/osd-2.5.0.tar.gz b/cypress/test-data/with-security/osd-2.5.0.tar.gz new file mode 100644 index 000000000000..8ad128f0efc3 Binary files /dev/null and b/cypress/test-data/with-security/osd-2.5.0.tar.gz differ diff --git a/cypress/test-data/without-security/osd-2.5.0.tar.gz b/cypress/test-data/without-security/osd-2.5.0.tar.gz new file mode 100644 index 000000000000..16c8ccc2e03b Binary files /dev/null and b/cypress/test-data/without-security/osd-2.5.0.tar.gz differ diff --git a/package.json b/package.json index 605bcb1865d6..ec639eba7178 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@hapi/podium": "^4.1.3", "@hapi/vision": "^6.1.0", "@hapi/wreck": "^17.1.0", - "@opensearch-project/opensearch": "^1.1.0", + "@opensearch-project/opensearch": "^2.1.0", "@osd/ace": "1.0.0", "@osd/analytics": "1.0.0", "@osd/apm-config-loader": "1.0.0", @@ -166,6 +166,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", @@ -334,6 +335,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", @@ -457,7 +459,7 @@ "vega-interpreter": "npm:@amoo-miki/vega-forced-csp-compliant-interpreter@1.0.6", "vega-lite": "^5.6.0", "vega-schema-url-parser": "^2.1.0", - "vega-tooltip": "^0.24.2", + "vega-tooltip": "^0.30.0", "vinyl-fs": "^3.0.3", "xml2js": "^0.4.22", "xmlbuilder": "13.0.2", diff --git a/packages/osd-opensearch/package.json b/packages/osd-opensearch/package.json index 88a6733757a6..4a9aafa875a5 100644 --- a/packages/osd-opensearch/package.json +++ b/packages/osd-opensearch/package.json @@ -12,7 +12,7 @@ "osd:watch": "node scripts/build --watch" }, "dependencies": { - "@opensearch-project/opensearch": "^1.1.0", + "@opensearch-project/opensearch": "^2.1.0", "@osd/dev-utils": "1.0.0", "abort-controller": "^3.0.0", "chalk": "^4.1.0", diff --git a/release-notes/opensearch-dashboards.release-notes-1.3.8.md b/release-notes/opensearch-dashboards.release-notes-1.3.8.md new file mode 100644 index 000000000000..b14e84018da1 --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-1.3.8.md @@ -0,0 +1,29 @@ +# Version 1.3.8 Release Notes + +### 🛡 Security + +- [CVE-2022-25901] Bump supertest from 2.0.5 to 2.0.12 ([#3326](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3326)) +- [CVE-2022-25860] Bump simple-git from 3.15.1 to 3.16.0 ([#3345](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3345)) +- [CVE-2022-46175] Bump json5 version from 1.0.1 and 2.2.1 to 1.0.2 and 2.2.3 ([#3201](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3201)) +- [CVE-2022-25912] Bump simple-git from 3.4.0 to 3.15.0 ([#3036](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3036)) +- Bump decode-uri-component from 0.2.0 to 0.2.2 ([#3009](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3009)) + +### 🐛 Bug Fixes + +- [BUG] Fixes misleading embeddable plugin error message ([#3043](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3043)) +- [BUG] Trim trailing slashes before checking no-restricted-path rule ([#3020](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3020)) + +### 🚞 Infrastructure + +- Lock workflow tests to Chrome and ChromeDriver 107 as the last combination that run on Node.js v10 ([#3299](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3299)) +- Update yarn timeout for GitHub workflow on Windows ([#3118](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3118)) +- Add Windows CI to the GitHub workflow ([#2966](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2966)) + +### 📝 Documentation + +- Fix documentation link for date math ([#3207](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3207)) + +### 🔩 Tests + +- [BWC] Updates to BWC tests ([#1190](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/1190)) +- Automates chromedriver version selection for tests ([#2990](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2990)) \ No newline at end of file diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 8dd5240f69de..c7e31c37f7e2 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/aws': + '/node_modules/@opensearch-project/opensearch/lib/aws', '^src/plugins/(.*)': '/src/plugins/$1', '^test_utils/(.*)': '/src/test_utils/public/$1', '^fixtures/(.*)': '/src/fixtures/$1', diff --git a/src/dev/notice/generate_notice_from_source.ts b/src/dev/notice/generate_notice_from_source.ts index 458d123c934e..0bfc2a978e0f 100644 --- a/src/dev/notice/generate_notice_from_source.ts +++ b/src/dev/notice/generate_notice_from_source.ts @@ -37,7 +37,7 @@ const NOTICE_TEXT = `Copyright OpenSearch Contributors This product includes software, including Kibana source code, developed by Elasticsearch (http://www.elastic.co). -Copyright 2009-2018 Elasticsearch B.V. +Copyright 2009-2021 Elasticsearch B.V. This product includes software developed by The Apache Software Foundation (http://www.apache.org/) diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index f9159b3246ff..a1e52fbb66a1 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -96,7 +96,7 @@ export class DataServerPlugin this.autocompleteService = new AutocompleteService(initializerContext); } - public setup( + public async setup( core: CoreSetup, { expressions, usageCollection, dataSource }: DataPluginSetupDependencies ) { @@ -108,7 +108,7 @@ export class DataServerPlugin core.uiSettings.register(getUiSettings()); - const searchSetup = this.searchService.setup(core, { + const searchSetup = await this.searchService.setup(core, { registerFunction: expressions.registerFunction, usageCollection, dataSource, diff --git a/src/plugins/data/server/search/collectors/usage.ts b/src/plugins/data/server/search/collectors/usage.ts index d227dea8057c..6bf6a90c63bd 100644 --- a/src/plugins/data/server/search/collectors/usage.ts +++ b/src/plugins/data/server/search/collectors/usage.ts @@ -28,8 +28,7 @@ * under the License. */ -import { CoreSetup, PluginInitializerContext } from 'opensearch-dashboards/server'; -import { first } from 'rxjs/operators'; +import { CoreSetup } from 'opensearch-dashboards/server'; import { Usage } from './register'; import { ConfigSchema } from '../../../config'; @@ -40,16 +39,9 @@ export interface SearchUsage { trackSuccess(duration: number): Promise; } -export function usageProvider( - core: CoreSetup, - initializerContext: PluginInitializerContext -): SearchUsage { +export function usageProvider(core: CoreSetup, config: ConfigSchema): SearchUsage { const getTracker = (eventType: keyof Usage) => { return async (duration?: number) => { - const config = await initializerContext.config - .create() - .pipe(first()) - .toPromise(); if (config?.search?.usageTelemetry?.enabled) { const repository = await core .getStartServices() diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 1cece2277c09..4a608c3df7e9 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -53,7 +53,7 @@ describe('Search service', () => { describe('setup()', () => { it('exposes proper contract', async () => { - const setup = plugin.setup(mockCoreSetup, ({ + const setup = await plugin.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, registerFunction: jest.fn(), } as unknown) as SearchServiceSetupDependencies); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 0c33b95f4606..6620b88a0fe3 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -105,11 +105,15 @@ export class SearchService implements Plugin { private readonly logger: Logger ) {} - public setup( + public async setup( core: CoreSetup<{}, DataPluginStart>, { registerFunction, usageCollection, dataSource }: SearchServiceSetupDependencies - ): ISearchSetup { - const usage = usageCollection ? usageProvider(core, this.initializerContext) : undefined; + ): Promise { + const config = await this.initializerContext.config + .create() + .pipe(first()) + .toPromise(); + const usage = usageCollection ? usageProvider(core, config) : undefined; const router = core.http.createRouter(); const routeDependencies = { 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_pool.ts b/src/plugins/data_source/server/client/client_pool.ts index f492d6bc2898..288682ef2538 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'; 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 fa4044163610..e00b62f04882 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 cc9bcfd9b361..03d5d3f21d0b 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'; -import { Logger, SavedObject, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; +import { Client, ClientOptions } from '@opensearch-project/opensearch'; +import { Client as LegacyClient } from 'elasticsearch'; +import { Credentials } from 'aws-sdk'; +import { AwsSigv4Signer } from '@opensearch-project/opensearch/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..293f52ff43a5 --- /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'; +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..3a9b65634a28 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'; +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/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/src/plugins/region_map/common/constants/shared.ts b/src/plugins/region_map/common/constants/shared.ts index 6d82a3a33600..3dc3e6ce6e80 100644 --- a/src/plugins/region_map/common/constants/shared.ts +++ b/src/plugins/region_map/common/constants/shared.ts @@ -5,3 +5,4 @@ export const DEFAULT_MAP_CHOICE = 'default'; export const CUSTOM_MAP_CHOICE = 'custom'; +export const CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING = 'visualization:regionmap:customVectorMapMaxSize'; diff --git a/src/plugins/region_map/common/index.ts b/src/plugins/region_map/common/index.ts index bdda981590ef..f7f2062b4910 100644 --- a/src/plugins/region_map/common/index.ts +++ b/src/plugins/region_map/common/index.ts @@ -3,6 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DEFAULT_MAP_CHOICE, CUSTOM_MAP_CHOICE } from './constants/shared'; +import { + DEFAULT_MAP_CHOICE, + CUSTOM_MAP_CHOICE, + CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING, +} from './constants/shared'; -export { DEFAULT_MAP_CHOICE, CUSTOM_MAP_CHOICE }; +export { DEFAULT_MAP_CHOICE, CUSTOM_MAP_CHOICE, CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING }; diff --git a/src/plugins/region_map/public/choropleth_layer.js b/src/plugins/region_map/public/choropleth_layer.js index e0213108f64a..10d2389c5761 100644 --- a/src/plugins/region_map/public/choropleth_layer.js +++ b/src/plugins/region_map/public/choropleth_layer.js @@ -37,7 +37,11 @@ import { getNotifications } from './opensearch_dashboards_services'; import { colorUtil, OpenSearchDashboardsMapLayer } from '../../maps_legacy/public'; import { truncatedColorMaps } from '../../charts/public'; import { getServices } from './services'; -import { DEFAULT_MAP_CHOICE, CUSTOM_MAP_CHOICE } from '../common'; +import { + DEFAULT_MAP_CHOICE, + CUSTOM_MAP_CHOICE, + CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING, +} from '../common'; const EMPTY_STYLE = { weight: 1, @@ -94,7 +98,8 @@ export class ChoroplethLayer extends OpenSearchDashboardsMapLayer { serviceSettings, leaflet, layerChosenByUser, - http + http, + uiSettings ) { super(); this._serviceSettings = serviceSettings; @@ -112,6 +117,7 @@ export class ChoroplethLayer extends OpenSearchDashboardsMapLayer { this._layerChosenByUser = layerChosenByUser; this._http = http; this._visParams = null; + this._uiSettings = uiSettings; // eslint-disable-next-line no-undef this._leafletLayer = this._leaflet.geoJson(null, { @@ -241,7 +247,8 @@ CORS configuration of the server permits requests from the OpenSearch Dashboards // fetch data from index and transform it to feature collection try { const services = getServices(this._http); - const result = await services.getIndexData(this._layerName); + const indexSize = this._uiSettings.get(CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING); + const result = await services.getIndexData(this._layerName, indexSize); const finalResult = { type: 'FeatureCollection', @@ -337,7 +344,8 @@ CORS configuration of the server permits requests from the OpenSearch Dashboards serviceSettings, leaflet, layerChosenByUser, - http + http, + uiSettings ) { const clonedLayer = new ChoroplethLayer( name, @@ -349,7 +357,8 @@ CORS configuration of the server permits requests from the OpenSearch Dashboards serviceSettings, leaflet, layerChosenByUser, - http + http, + uiSettings ); clonedLayer.setJoinField(this._joinField); clonedLayer.setColorRamp(this._colorRamp); diff --git a/src/plugins/region_map/public/components/map_choice_options.tsx b/src/plugins/region_map/public/components/map_choice_options.tsx index f18cf88b3712..f08f026233a6 100644 --- a/src/plugins/region_map/public/components/map_choice_options.tsx +++ b/src/plugins/region_map/public/components/map_choice_options.tsx @@ -4,7 +4,7 @@ */ import './map_choice_options.scss'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiPanel, EuiSpacer, diff --git a/src/plugins/region_map/public/region_map_visualization.js b/src/plugins/region_map/public/region_map_visualization.js index 81afee4671ca..69dca0525b89 100644 --- a/src/plugins/region_map/public/region_map_visualization.js +++ b/src/plugins/region_map/public/region_map_visualization.js @@ -230,7 +230,8 @@ export function createRegionMapVisualization({ await getServiceSettings(), (await lazyLoadMapsLegacyModules()).L, this._params.layerChosenByUser, - http + http, + uiSettings ); } else { const { ChoroplethLayer } = await import('./choropleth_layer'); @@ -244,7 +245,8 @@ export function createRegionMapVisualization({ await getServiceSettings(), (await lazyLoadMapsLegacyModules()).L, this._params.layerChosenByUser, - http + http, + uiSettings ); } this._choroplethLayer.setLayerChosenByUser(this._params.layerChosenByUser); diff --git a/src/plugins/region_map/public/services.ts b/src/plugins/region_map/public/services.ts index dd26be3ccd7a..ba182c7aa0fa 100644 --- a/src/plugins/region_map/public/services.ts +++ b/src/plugins/region_map/public/services.ts @@ -7,7 +7,7 @@ import { CoreStart, HttpFetchError } from 'opensearch-dashboards/public'; export interface Services { getCustomIndices: () => Promise; - getIndexData: (indexName: string) => Promise; + getIndexData: (indexName: string, size: number) => Promise; getIndexMapping: (indexName: string) => Promise; } @@ -25,11 +25,12 @@ export function getServices(http: CoreStart['http']): Services { return e; } }, - getIndexData: async (indexName: string) => { + getIndexData: async (indexName: string, size: number) => { try { const response = await http.post('../api/geospatial/_search', { body: JSON.stringify({ index: indexName, + size, }), }); return response; diff --git a/src/plugins/region_map/server/routes/opensearch.ts b/src/plugins/region_map/server/routes/opensearch.ts index 5eebc9a0ffda..dfdcb4a1900d 100644 --- a/src/plugins/region_map/server/routes/opensearch.ts +++ b/src/plugins/region_map/server/routes/opensearch.ts @@ -57,14 +57,15 @@ export function registerGeospatialRoutes(router: IRouter) { validate: { body: schema.object({ index: schema.string(), + size: schema.number(), }), }, }, async (context, req, res) => { const client = context.core.opensearch.client.asCurrentUser; try { - const { index } = req.body; - const params = { index, body: {} }; + const { index, size } = req.body; + const params = { index, body: {}, size }; const results = await client.search(params); return res.ok({ body: { diff --git a/src/plugins/region_map/server/ui_settings.ts b/src/plugins/region_map/server/ui_settings.ts index e5c90f6779b1..037ee4b67b9a 100644 --- a/src/plugins/region_map/server/ui_settings.ts +++ b/src/plugins/region_map/server/ui_settings.ts @@ -31,6 +31,7 @@ import { i18n } from '@osd/i18n'; import { UiSettingsParams } from 'opensearch-dashboards/server'; import { schema } from '@osd/config-schema'; +import { CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING } from '../common'; export function getUiSettings(): Record> { return { @@ -49,5 +50,20 @@ export function getUiSettings(): Record> { schema: schema.boolean(), category: ['visualization'], }, + [CUSTOM_VECTOR_MAP_MAX_SIZE_SETTING]: { + name: i18n.translate('regionMap.advancedSettings.visualization.customVectorMapDefaultSize', { + defaultMessage: 'Custom vector map size', + }), + value: 1000, + description: i18n.translate( + 'regionMap.advancedSettings.visualization.customVectorMapDefaultSizeText', + { + defaultMessage: + 'The maximum number of features to load from custom vector map. A higher number might have negative impact on browser rendering performance.', + } + ), + schema: schema.number(), + category: ['visualization'], + }, }; } diff --git a/src/setup_node_env/node_version_validator.test.js b/src/setup_node_env/node_version_validator.test.js index cb3639154c6c..92c596548207 100644 --- a/src/setup_node_env/node_version_validator.test.js +++ b/src/setup_node_env/node_version_validator.test.js @@ -28,10 +28,10 @@ * under the License. */ -var exec = require('child_process').exec; -var pkg = require('../../package.json'); +const exec = require('child_process').exec; +const pkg = require('../../package.json'); -var REQUIRED_NODE_JS_VERSION = 'v' + pkg.engines.node; +const REQUIRED_NODE_JS_VERSION = 'v' + pkg.engines.node; describe('NodeVersionValidator', function () { it('should run the script WITHOUT error when the version is the same', function (done) { @@ -43,7 +43,7 @@ describe('NodeVersionValidator', function () { }); it('should run the script WITH error if the patch version is lower', function (done) { - var lowerPatchversion = requiredNodeVersionWithDiff(0, 0, -1); + const lowerPatchversion = requiredNodeVersionWithDiff(0, 0, -1); testValidateNodeVersion( done, lowerPatchversion, @@ -56,7 +56,7 @@ describe('NodeVersionValidator', function () { }); it('should run the script WITH error if the major version is lower', function (done) { - var lowerMajorVersion = requiredNodeVersionWithDiff(-1, 0, 0); + const lowerMajorVersion = requiredNodeVersionWithDiff(-1, 0, 0); testValidateNodeVersion( done, lowerMajorVersion, @@ -69,7 +69,7 @@ describe('NodeVersionValidator', function () { }); it('should run the script WITH error if the minor version is lower', function (done) { - var lowerMinorVersion = requiredNodeVersionWithDiff(0, -1, 0); + const lowerMinorVersion = requiredNodeVersionWithDiff(0, -1, 0); testValidateNodeVersion( done, lowerMinorVersion, @@ -79,24 +79,24 @@ describe('NodeVersionValidator', function () { }); function requiredNodeVersionWithDiff(majorDiff, minorDiff, patchDiff) { - var matches = REQUIRED_NODE_JS_VERSION.match(/^v(\d+)\.(\d+)\.(\d+)/); - var major = Math.max(parseInt(matches[1], 10) + majorDiff, 0); - var minor = Math.max(parseInt(matches[2], 10) + minorDiff, 0); - var patch = Math.max(parseInt(matches[3], 10) + patchDiff, 0); + const matches = REQUIRED_NODE_JS_VERSION.match(/^v(\d+)\.(\d+)\.(\d+)/); + const major = Math.max(parseInt(matches[1], 10) + majorDiff, 0); + const minor = Math.max(parseInt(matches[2], 10) + minorDiff, 0); + const patch = Math.max(parseInt(matches[3], 10) + patchDiff, 0); return `v${major}.${minor}.${patch}`; } function testValidateNodeVersion(done, versionToTest, expectError = false) { - var processVersionOverwrite = `Object.defineProperty(process, 'version', { value: '${versionToTest}', writable: true });`; - var command = `node -e "${processVersionOverwrite}require('./node_version_validator.js')"`; + const processVersionOverwrite = `Object.defineProperty(process, 'version', { value: '${versionToTest}', writable: true });`; + const command = `node -e "${processVersionOverwrite}require('./node_version_validator.js')"`; exec(command, { cwd: __dirname }, function (error, _stdout, stderr) { expect(stderr).toBeDefined(); if (expectError) { expect(error.code).toBe(1); - var speficicErrorMessage = + const speficicErrorMessage = `OpenSearch Dashboards was built with ${REQUIRED_NODE_JS_VERSION} and does not support the current Node.js version ${versionToTest}. ` + `Please use Node.js ${REQUIRED_NODE_JS_VERSION} or a higher patch version.\n`; diff --git a/src/setup_node_env/root/force.test.js b/src/setup_node_env/root/force.test.js index f48f3255e4ed..eca20f9547b2 100644 --- a/src/setup_node_env/root/force.test.js +++ b/src/setup_node_env/root/force.test.js @@ -28,7 +28,7 @@ * under the License. */ -var forceRoot = require('./force'); +const forceRoot = require('./force'); describe('forceRoot', function () { it('with flag', function () { @@ -40,7 +40,7 @@ describe('forceRoot', function () { }); test('remove argument', function () { - var args = ['--allow-root', 'foo']; + const args = ['--allow-root', 'foo']; forceRoot(args); expect(args.includes('--allow-root')).toBeFalsy(); }); diff --git a/src/setup_node_env/root/is_root.test.js b/src/setup_node_env/root/is_root.test.js index 81dfbe3616cb..e7f8e0670b77 100644 --- a/src/setup_node_env/root/is_root.test.js +++ b/src/setup_node_env/root/is_root.test.js @@ -28,7 +28,7 @@ * under the License. */ -var isRoot = require('./is_root'); +const isRoot = require('./is_root'); describe('isRoot', function () { test('0 is root', function () { diff --git a/yarn.lock b/yarn.lock index d4c3e3187449..3e117b0c09e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1645,9 +1645,9 @@ "@hapi/hoek" "9.x.x" "@hapi/statehood@^7.0.3": - version "7.0.3" - resolved "https://registry.yarnpkg.com/@hapi/statehood/-/statehood-7.0.3.tgz#655166f3768344ed3c3b50375a303cdeca8040d9" - integrity sha512-pYB+pyCHkf2Amh67QAXz7e/DN9jcMplIL7Z6N8h0K+ZTy0b404JKPEYkbWHSnDtxLjJB/OtgElxocr2fMH4G7w== + version "7.0.4" + resolved "https://registry.yarnpkg.com/@hapi/statehood/-/statehood-7.0.4.tgz#6acb9d0817b5c657089356f7d9fd60af0bce4f41" + integrity sha512-Fia6atroOVmc5+2bNOxF6Zv9vpbNAjEXNcUbWXavDqhnJDlchwUUwKS5LCi5mGtCTxRhUKKHwuxuBZJkmLZ7fw== dependencies: "@hapi/boom" "9.x.x" "@hapi/bounce" "2.x.x" @@ -2338,11 +2338,12 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@opensearch-project/opensearch@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-1.1.0.tgz#8b3c8b4cbcea01755ba092d2997bf0b4ca7f22f7" - integrity sha512-1TDw92JL8rD1b2QGluqBsIBLIiD5SGciIpz4qkrGAe9tcdfQ1ptub5e677rhWl35UULSjr6hP8M6HmISZ/M5HQ== +"@opensearch-project/opensearch@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-2.1.0.tgz#d79ab4ae643493512099673e117faffe40b4fe56" + integrity sha512-iM2u63j2IlUOuMSbcw1TZFpRqjK6qMwVhb3jLLa/x4aATxdKOiO1i17mgzfkeepqj85efNzXBZzN+jkq1/EXhQ== dependencies: + aws4 "^1.11.0" debug "^4.3.1" hpagent "^0.1.1" ms "^2.1.3" @@ -2540,9 +2541,9 @@ "@hapi/hoek" "^9.0.0" "@sideway/formula@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" - integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== "@sideway/pinpoint@^2.0.0": version "2.0.0" @@ -2850,7 +2851,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== @@ -3054,6 +3055,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" @@ -4709,6 +4719,27 @@ aws-sdk@^2.650.0: uuid "8.0.0" xml2js "0.4.19" +aws-sdk@^2.814.0: + version "2.1271.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1271.0.tgz#a060fe65ff33afddb7385913200df4a26717f691" + integrity sha512-hQF+mjwe2FXFKOMNQGlfqn9InIL1bRp650ftctRqDo+VpnrYnKqF9eZa5Hk2kugs3/WUa4J2aqQa+foGWeH+Fg== + 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" + +aws4@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + axe-core@^4.0.2, axe-core@^4.3.5: version "4.4.1" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413" @@ -9676,10 +9707,15 @@ 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.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== http-headers@^3.0.2: version "3.0.2" @@ -17769,12 +17805,12 @@ vega-time@^2.0.3, vega-time@^2.1.0, vega-time@~2.1.0: d3-time "^3.0.0" vega-util "^1.15.2" -vega-tooltip@^0.24.2: - version "0.24.2" - resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.24.2.tgz#da55a171a96ea48a8ff135a728622e1cbb1152af" - integrity sha512-b7IeYQl/piNVsMmTliOgTnwSOhBs67KqoZ9UzP1I3XpH7TKbSuc3YHA7b1CSxkRR0hHKdradby4UI8c9rdH74w== +vega-tooltip@^0.30.0: + version "0.30.0" + resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.30.0.tgz#b8a48a0d1be717b7410cf75021aaaff75818b212" + integrity sha512-dBuqp1HgNvxrc3MU4KAE3gU7AiD0AvCiyu7IMwubI6TQa0l9A5c+B+ZLjDZP2Ool0J9eAaGgVhqjXWaUjUAfAQ== dependencies: - vega-util "^1.15.2" + vega-util "^1.17.0" vega-transforms@~4.10.0: version "4.10.0"