diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5649019bd089..3a722b6255a8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @kristenTian @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe +* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @kristenTian @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 16c2d3f4011b..7e69169e6d61 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -6,7 +6,7 @@ name: Build and test # trigger on every commit push and PR for all branches except pushes for backport branches on: push: - branches: ['**', '!backport/**'] + branches: ['main', '[0-9].x', '[0-9].[0=9]+'] # Run the functional test on push for only release branches paths-ignore: - '**/*.md' - 'docs/**' diff --git a/CHANGELOG.md b/CHANGELOG.md index d49655b69e70..ec8685b699f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Deprecations +### ๐Ÿ›ก Security + +### ๐Ÿ“ˆ Features/Enhancements + +### ๐Ÿ› Bug Fixes + +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [BUG][Discover] Add key to index pattern options for support deplicate index pattern names([#5946](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5946)) +- [Discover] Fix table cell content overflowing in Safari ([#5948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5948)) +- [BUG][MD]Fix schema for test connection to separate validation based on auth type([#5997](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5997)) + +### ๐Ÿšž Infrastructure + +### ๐Ÿ“ Documentation + +- Fix link to documentation for geoHash precision ([#5967](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5967)) + +### ๐Ÿ›  Maintenance + +### ๐Ÿช› Refactoring + +### ๐Ÿ”ฉ Tests + +## [2.12.0 - 2024-02-20](https://github.com/opensearch-project/OpenSearch-Dashboards/releases/tag/2.12.0) + +### ๐Ÿ’ฅ Breaking Changes + +### Deprecations + - Rename `withLongNumerals` to `withLongNumeralsSupport` in `HttpFetchOptions` [#5592](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5592) ### ๐Ÿ›ก Security @@ -41,6 +70,12 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add interfaces to register add-on authentication method from plug-in module ([#5851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5851)) - [Multiple Datasource] Able to Hide "Local Cluster" option from datasource DropDown ([#5827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5827)) - [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) +- [Multiple Datasource] Concatenate data source name with index pattern name and change delimiter to double colon ([#5907](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5907)) +- [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) +- [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) +- [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) +- [[Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) +- [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) ### ๐Ÿ› Bug Fixes @@ -68,6 +103,9 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG] Remove duplicate sample data as id 90943e30-9a47-11e8-b64d-95841ca0b247 ([5668](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5668)) - [BUG][Multiple Datasource] Fix datasource testing connection unexpectedly passed with wrong endpoint [#5663](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5663) - [Table Visualization] Fix filter action buttons for split table aggregations ([#5619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5619)) +- [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956)) +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [BUG][Multiple Datasource] Fix missing customApiRegistryPromise param for test connection ([#5944](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5944)) ### ๐Ÿšž Infrastructure @@ -79,6 +117,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Chore] Add `--security` for `opensearch snapshot` and `opensearch_dashboards` to configure local setup with the security plugin ([#5451](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5451)) - [Tests] Add Github workflow for Test Orchestrator in FT Repo to run cypress tests within Dashboards repo ([#5725](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5725)) - [Chore] Updates default dev environment security credentials ([#5736](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5736)) +- [Tests] Baseline screenshots for area and tsvb functional tests ([#5915](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5915)) ### ๐Ÿ“ Documentation @@ -99,6 +138,9 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add @SuZhou-Joe as a maintainer ([#5594](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5594)) - Move @seanneumann to emeritus maintainer ([#5634](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5634)) - Remove `ui-select` dev dependency ([#5660](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5660)) +- Bump `chromedriver` dependency to `121.0.1"` ([#5926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5926)) +- Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) +- Add @BionIT as a maintainer ([#5988](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5988)) ### ๐Ÿช› Refactoring diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 83709bd6209a..ccb2f491554e 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -22,6 +22,8 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Sirazh Gabdullin | [curq](https://github.com/curq) | External contributor | | Bandini Bhopi | [bandinib-amzn](https://github.com/bandinib-amzn) | Amazon | | Su Zhou | [SuZhou-Joe](https://github.com/SuZhou-Joe) | Amazon | +| Yulong Ruan | [ruanyl](https://github.com/ruanyl) | Amazon | +| Lu Yu | [BionIT](https://github.com/BionIT) | Amazon | ## Emeritus diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 0e5beac120c0..56f8e28fa94d 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -29,6 +29,13 @@ # dashboards. OpenSearch Dashboards creates a new index if the index doesn't already exist. #opensearchDashboards.index: ".opensearch_dashboards" +# OpenSearch Dashboards uses an index in OpenSearch to store dynamic configurations. +# This shall be a different index from opensearchDashboards.index. +# opensearchDashboards.configIndex: ".opensearch_dashboards_config" + +# Set the value of this setting to true to enable plugin application config. By default it is disabled. +# application_config.enabled: false + # The default application to load. #opensearchDashboards.defaultAppId: "home" @@ -270,13 +277,24 @@ # 'ff00::/8', # ] +# Set enabled false to hide authentication method in OpenSearch Dashboards. +# If this setting is commented then all 3 options will be available in OpenSearch Dashboards. +# Default value will be considered to True. +#data_source.authTypes: +# NoAuthentication: +# enabled: true +# UsernamePassword: +# enabled: true +# AWSSigV4: +# enabled: true + # Set the value of this setting to false to hide the help menu link to the OpenSearch Dashboards user survey # opensearchDashboards.survey.url: "https://survey.opensearch.org" # Set the value of this setting to true to enable plugin augmentation on Dashboard # vis_augmenter.pluginAugmentationEnabled: true -# Set the value to true enable workspace feature +# Set the value to true to enable workspace feature # workspace.enabled: false # Set the value to false to disable permission check on workspace # Permission check depends on OpenSearch Dashboards has authentication enabled, set it to false if no authentication is configured diff --git a/package.json b/package.json index cb24289e686f..6305cbed15bf 100644 --- a/package.json +++ b/package.json @@ -357,7 +357,7 @@ "chai": "3.5.0", "chance": "1.0.18", "cheerio": "1.0.0-rc.1", - "chromedriver": "^119.0.1", + "chromedriver": "^121.0.1", "classnames": "2.3.1", "compare-versions": "3.5.1", "cypress": "9.5.4", diff --git a/packages/osd-std/src/json.ts b/packages/osd-std/src/json.ts index 4761eadea2dd..3af3d3c4b96c 100644 --- a/packages/osd-std/src/json.ts +++ b/packages/osd-std/src/json.ts @@ -150,16 +150,38 @@ const parseStringWithLongNumerals = ( } catch (e) { hadException = true; /* There are two types of exception objects that can be raised: - * 1) a proper object with lineNumber and columnNumber which we can use - * 2) a textual message with the position that we need to parse + * 1) a textual message with the position that we need to parse + * i. Unexpected [token|string ...] at position ... + * ii. expected ',' or '}' after property value in object at line ... column ... + * 2) a proper object with lineNumber and columnNumber which we can use + * Note: this might refer to the part of the code that threw the exception but + * we will try it anyway; the regex is specific enough to not produce + * false-positives. */ let { lineNumber, columnNumber } = e; - if (!lineNumber || !columnNumber) { - const match = e?.message?.match?.(/^Unexpected token.*at position (\d+)$/); + + if (typeof e?.message === 'string') { + /* Check for 1-i (seen in Node) + * Finding "..."เทด1111"..." inside a string value, the extra quotes throw a syntax error + * and the position points to " that is assumed to be the begining of a value. + */ + let match = e.message.match(/^Unexpected .*at position (\d+)(\s|$)/); if (match) { lineNumber = 1; - // The position is zero-indexed; adding 1 to normalize it for the -2 that comes later + // Add 1 to reach the marker columnNumber = parseInt(match[1], 10) + 1; + } else { + /* Check for 1-ii (seen in browsers) + * Finding "...,"เทด1111"..." inside a string value, the extra quotes throw a syntax error + * and the column number points to the marker after the " that is assumed to be terminating the + * value. + */ + // ToDo: Add functional tests for this path + match = e.message.match(/expected .*at line (\d+) column (\d+)(\s|$)/); + if (match) { + lineNumber = parseInt(match[1], 10); + columnNumber = parseInt(match[2], 10); + } } } diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2a6114013b22..687d408e40a6 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -77,6 +77,7 @@ export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { opensearchDashboards: { index: '.opensearch_dashboards_tests', + configIndex: '.opensearch_dashboards_config_tests', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), }, diff --git a/src/core/server/opensearch_dashboards_config.ts b/src/core/server/opensearch_dashboards_config.ts index 107d02ea3377..47fa8a126501 100644 --- a/src/core/server/opensearch_dashboards_config.ts +++ b/src/core/server/opensearch_dashboards_config.ts @@ -48,6 +48,7 @@ export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), index: schema.string({ defaultValue: '.kibana' }), + configIndex: schema.string({ defaultValue: '.opensearch_dashboards_config' }), autocompleteTerminateAfter: schema.duration({ defaultValue: 100000 }), autocompleteTimeout: schema.duration({ defaultValue: 1000 }), branding: schema.object({ diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 48c9eb6d6823..7a8ba042825b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -98,6 +98,7 @@ describe('createPluginInitializerContext', () => { expect(configObject).toStrictEqual({ opensearchDashboards: { index: '.kibana', + configIndex: '.opensearch_dashboards_config', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), }, diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index b7667b5bd2d2..59b9881279c3 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -287,7 +287,12 @@ export interface Plugin< export const SharedGlobalConfigKeys = { // We can add more if really needed - opensearchDashboards: ['index', 'autocompleteTerminateAfter', 'autocompleteTimeout'] as const, + opensearchDashboards: [ + 'index', + 'configIndex', + 'autocompleteTerminateAfter', + 'autocompleteTimeout', + ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const, path: ['data'] as const, savedObjects: ['maxImportPayloadBytes'] as const, diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 67bc8dfc6227..4bb07150aef8 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -39,9 +39,9 @@ interface CreateSavedObjectsParams { importIdMap: Map; namespace?: string; overwrite?: boolean; - workspaces?: string[]; dataSourceId?: string; dataSourceTitle?: string; + workspaces?: string[]; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -59,9 +59,9 @@ export const createSavedObjects = async ({ importIdMap, namespace, overwrite, - workspaces, dataSourceId, dataSourceTitle, + workspaces, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index a3d10c6f1ace..24bbc1934de3 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -59,9 +59,9 @@ export async function resolveSavedObjectsImportErrors({ typeRegistry, namespace, createNewCopies, - workspaces, dataSourceId, dataSourceTitle, + workspaces, }: SavedObjectsResolveImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -163,9 +163,9 @@ export async function resolveSavedObjectsImportErrors({ importIdMap, namespace, overwrite, - workspaces, dataSourceId, dataSourceTitle, + workspaces, }; const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( createSavedObjectsParams diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index d0433b72766a..08c0f23b4331 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -191,6 +191,8 @@ export interface SavedObjectsImportOptions { workspaces?: string[]; dataSourceId?: string; dataSourceTitle?: string; + /** if specified, will import in given workspaces */ + workspaces?: string[]; } /** @@ -216,6 +218,8 @@ export interface SavedObjectsResolveImportErrorsOptions { workspaces?: string[]; dataSourceId?: string; dataSourceTitle?: string; + /** if specified, will import in given workspaces */ + workspaces?: string[]; } export type CreatedObject = SavedObject & { destinationId?: string }; diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts index c70dbbb241bc..4acc161c4bab 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts @@ -30,6 +30,7 @@ import { IndexMapping, SavedObjectsTypeMappingDefinitions } from './../../mappings'; import { buildActiveMappings, diffMappings } from './build_active_mappings'; +import { configMock } from '../../../config/mocks'; describe('buildActiveMappings', () => { test('creates a strict mapping', () => { @@ -91,6 +92,12 @@ describe('buildActiveMappings', () => { expect(hashes.aaa).toEqual(hashes.bbb); expect(hashes.aaa).not.toEqual(hashes.ccc); }); + + test('workspaces field is added when workspace feature flag is enabled', () => { + const rawConfig = configMock.create(); + rawConfig.get.mockReturnValue(true); + expect(buildActiveMappings({}, rawConfig)).toHaveProperty('properties.workspaces'); + }); }); describe('diffMappings', () => { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 05fb534f7a11..8f301debf6f7 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -34,6 +34,7 @@ import crypto from 'crypto'; import { cloneDeep, mapValues } from 'lodash'; +import { Config } from 'packages/osd-config/target'; import { IndexMapping, SavedObjectsFieldMapping, @@ -48,11 +49,20 @@ import { * @param typeDefinitions - the type definitions to build mapping from. */ export function buildActiveMappings( - typeDefinitions: SavedObjectsTypeMappingDefinitions | SavedObjectsMappingProperties + typeDefinitions: SavedObjectsTypeMappingDefinitions | SavedObjectsMappingProperties, + opensearchDashboardsRawConfig?: Config ): IndexMapping { const mapping = defaultMapping(); - const mergedProperties = validateAndMerge(mapping.properties, typeDefinitions); + let mergedProperties = validateAndMerge(mapping.properties, typeDefinitions); + // if permission control for saved objects is enabled, the permissions field should be added to the mapping + if (opensearchDashboardsRawConfig?.get('workspace.enabled')) { + mergedProperties = validateAndMerge(mapping.properties, { + workspaces: { + type: 'keyword', + }, + }); + } return cloneDeep({ ...mapping, @@ -186,9 +196,6 @@ function defaultMapping(): IndexMapping { }, }, }, - workspaces: { - type: 'keyword', - }, permissions: { properties: { read: principals, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 08bc4162a807..3188ff87afe8 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -36,6 +36,7 @@ import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { IndexMigrator } from './index_migrator'; import { MigrationOpts } from './migration_context'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; +import { configMock } from '../../../config/mocks'; describe('IndexMigrator', () => { let testOpts: jest.Mocked & { @@ -59,6 +60,60 @@ describe('IndexMigrator', () => { }; }); + test('creates the index when workspaces feature flag is enabled', async () => { + const { client } = testOpts; + + testOpts.mappingProperties = { foo: { type: 'long' } as any }; + const rawConfig = configMock.create(); + rawConfig.get.mockReturnValue(true); + testOpts.opensearchDashboardsRawConfig = rawConfig; + + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); + + await new IndexMigrator(testOpts).migrate(); + + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + _meta: { + migrationMappingPropertyHashes: { + foo: '18c78c995965207ed3f6e7fc5c6e55fe', + migrationVersion: '4a1746014a75ade3a714e1db5763276f', + namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', + references: '7997cf5a56cc02bdc9c93361bde732b0', + type: '2f4316de49999235636386fe51dc06c1', + updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', + }, + }, + properties: { + foo: { type: 'long' }, + migrationVersion: { dynamic: 'true', type: 'object' }, + namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, + type: { type: 'keyword' }, + updated_at: { type: 'date' }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, + workspaces: { type: 'keyword' }, + }, + }, + settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, + }, + index: '.kibana_1', + }); + }); + test('creates the index if it does not exist', async () => { const { client } = testOpts; diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 82001f7ed4c4..8a1e9b648bce 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -36,6 +36,7 @@ */ import { Logger } from 'src/core/server/logging'; +import { Config } from 'packages/osd-config/target'; import { MigrationOpenSearchClient } from './migration_opensearch_client'; import { SavedObjectsSerializer } from '../../serialization'; import { @@ -65,6 +66,7 @@ export interface MigrationOpts { * prior to running migrations. For example: 'opensearch_dashboards_index_template*' */ obsoleteIndexTemplatePattern?: string; + opensearchDashboardsRawConfig?: Config; } /** @@ -90,10 +92,15 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, client } = opts; + const { log, client, opensearchDashboardsRawConfig } = opts; const alias = opts.index; const source = createSourceContext(await Index.fetchInfo(client, alias), alias); - const dest = createDestContext(source, alias, opts.mappingProperties); + const dest = createDestContext( + source, + alias, + opts.mappingProperties, + opensearchDashboardsRawConfig + ); return { client, @@ -125,10 +132,11 @@ function createSourceContext(source: Index.FullIndexInfo, alias: string) { function createDestContext( source: Index.FullIndexInfo, alias: string, - typeMappingDefinitions: SavedObjectsTypeMappingDefinitions + typeMappingDefinitions: SavedObjectsTypeMappingDefinitions, + opensearchDashboardsRawConfig?: Config ): Index.FullIndexInfo { const targetMappings = disableUnknownTypeMappingFields( - buildActiveMappings(typeMappingDefinitions), + buildActiveMappings(typeMappingDefinitions, opensearchDashboardsRawConfig), source.mappings ); diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts index 32a1bc51a554..b0350a00b211 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts @@ -37,6 +37,7 @@ import { import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; +import { configMock } from '../../../config/mocks'; const createRegistry = (types: Array>) => { const registry = new SavedObjectTypeRegistry(); @@ -76,6 +77,12 @@ describe('OpenSearchDashboardsMigrator', () => { const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); expect(mappings).toMatchSnapshot(); }); + + it('workspaces field exists in the mappings when the feature is enabled', () => { + const options = mockOptions(true); + const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); + expect(mappings).toHaveProperty('properties.workspaces'); + }); }); describe('runMigrations', () => { @@ -146,7 +153,12 @@ type MockedOptions = OpenSearchDashboardsMigratorOptions & { client: ReturnType; }; -const mockOptions = () => { +const mockOptions = (isWorkspaceEnabled?: boolean) => { + const rawConfig = configMock.create(); + rawConfig.get.mockReturnValue(false); + if (isWorkspaceEnabled) { + rawConfig.get.mockReturnValue(true); + } const options: MockedOptions = { logger: loggingSystemMock.create().get(), opensearchDashboardsVersion: '8.2.3', @@ -186,6 +198,7 @@ const mockOptions = () => { skip: false, }, client: opensearchClientMock.createOpenSearchClient(), + opensearchDashboardsRawConfig: rawConfig, }; return options; }; diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.ts b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.ts index 284615083af3..468aea3e905d 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.ts +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.ts @@ -36,6 +36,7 @@ import { OpenSearchDashboardsConfigType } from 'src/core/server/opensearch_dashboards_config'; import { BehaviorSubject } from 'rxjs'; +import { Config } from 'packages/osd-config/target'; import { Logger } from '../../../logging'; import { IndexMapping, SavedObjectsTypeMappingDefinitions } from '../../mappings'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; @@ -54,6 +55,7 @@ export interface OpenSearchDashboardsMigratorOptions { opensearchDashboardsConfig: OpenSearchDashboardsConfigType; opensearchDashboardsVersion: string; logger: Logger; + opensearchDashboardsRawConfig: Config; } export type IOpenSearchDashboardsMigrator = Pick< @@ -83,6 +85,7 @@ export class OpenSearchDashboardsMigrator { status: 'waiting', }); private readonly activeMappings: IndexMapping; + private readonly opensearchDashboardsRawConfig: Config; /** * Creates an instance of OpenSearchDashboardsMigrator. @@ -94,6 +97,7 @@ export class OpenSearchDashboardsMigrator { savedObjectsConfig, opensearchDashboardsVersion, logger, + opensearchDashboardsRawConfig, }: OpenSearchDashboardsMigratorOptions) { this.client = client; this.opensearchDashboardsConfig = opensearchDashboardsConfig; @@ -107,9 +111,13 @@ export class OpenSearchDashboardsMigrator { typeRegistry, log: this.log, }); + this.opensearchDashboardsRawConfig = opensearchDashboardsRawConfig; // Building the active mappings (and associated md5sums) is an expensive // operation so we cache the result - this.activeMappings = buildActiveMappings(this.mappingProperties); + this.activeMappings = buildActiveMappings( + this.mappingProperties, + this.opensearchDashboardsRawConfig + ); } /** @@ -181,6 +189,7 @@ export class OpenSearchDashboardsMigrator { ? 'opensearch_dashboards_index_template*' : undefined, convertToAliasScript: indexMap[index].script, + opensearchDashboardsRawConfig: this.opensearchDashboardsRawConfig, }); }); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 6c7cc77d9b84..c8878439b6c8 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -64,6 +64,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -126,6 +129,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) workspaces, dataSourceId, dataSourceTitle, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index dedcc960a675..0ea91e43a777 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -62,6 +62,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.object({ file: schema.stream(), diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 64c8b6a5fbc8..2a1cff648fae 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -67,6 +67,7 @@ import { registerRoutes } from './routes'; import { ServiceStatus, ServiceStatusLevels } from '../status'; import { calculateStatus$ } from './status'; import { createMigrationOpenSearchClient } from './migrations/core/'; +import { Config } from '../config'; /** * Saved Objects is OpenSearchDashboards's data persistence mechanism allowing plugins to * use OpenSearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -315,6 +316,8 @@ export class SavedObjectsService summary: `waiting`, }); + private opensearchDashboardsRawConfig?: Config; + constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('savedobjects-service'); } @@ -332,6 +335,10 @@ export class SavedObjectsService .atPath('migrations') .pipe(first()) .toPromise(); + this.opensearchDashboardsRawConfig = await this.coreContext.configService + .getConfig$() + .pipe(first()) + .toPromise(); this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig); registerRoutes({ @@ -559,6 +566,7 @@ export class SavedObjectsService this.logger, migrationsRetryDelay ), + opensearchDashboardsRawConfig: this.opensearchDashboardsRawConfig as Config, }); } } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index a53684c833e2..c9990977bb48 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -71,6 +71,10 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { initialNamespaces?: string[]; /** permission control describe by ACL object */ permissions?: Permissions; + /** + * workspaces the new created objects belong to + */ + workspaces?: string[]; } /** diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 5cf8e9ac1901..a102268effca 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -227,6 +227,7 @@ export default () => opensearchDashboards: Joi.object({ enabled: Joi.boolean().default(true), index: Joi.string().default('.kibana'), + configIndex: Joi.string().default('.opensearch_dashboards_config'), autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), // TODO Also allow units here like in opensearch config once this is moved to the new platform autocompleteTimeout: Joi.number().integer().min(1).default(1000), diff --git a/src/plugins/application_config/README.md b/src/plugins/application_config/README.md new file mode 100755 index 000000000000..cad28722d63e --- /dev/null +++ b/src/plugins/application_config/README.md @@ -0,0 +1,112 @@ +# ApplicationConfig Plugin + +An OpenSearch Dashboards plugin for application configuration and a default implementation based on OpenSearch as storage. + +--- + +## Introduction + +This plugin introduces the support of dynamic application configurations as opposed to the existing static configuration in OSD YAML file `opensearch_dashboards.yml`. It stores the configuration in an index whose default name is `.opensearch_dashboards_config` and could be customized through the key `opensearchDashboards.configIndex` in OSD YAML file. Initially the new index does not exist. Only OSD users who need dynamic configurations will create it. + +It also provides an interface `ConfigurationClient` for future extensions of external configuration clients. A default implementation based on OpenSearch as database is used. + +This plugin is disabled by default. + +## Configuration + +OSD users who want to set up application configurations will first need to enable this plugin by the following line in OSD YML. + +``` +application_config.enabled: true + +``` + +Then they can perform configuration operations through CURL the OSD APIs. + +(Note that the commands following could be first obtained from a copy as curl option from the network tab of a browser development tool and then replaced with the API names) + +Below is the CURL command to view all configurations. + +``` +curl '{osd endpoint}/api/appconfig' -X GET +``` + +Below is the CURL command to view the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X GET + +``` + +Below is the CURL command to update the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"{new value}"}' +``` + +Below is the CURL command to delete the configuration of an entity. + +``` +curl '{osd endpoint}/api/appconfig/{entity}' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' + +``` + + +## External Configuration Clients + +While a default OpenSearch based client is implemented, OSD users can use external configuration clients through an OSD plugin (outside OSD). + +Let's call this plugin `MyConfigurationClientPlugin`. + +First, this plugin will need to implement a class `MyConfigurationClient` based on interface `ConfigurationClient` defined in the `types.ts` under directory `src/plugins/application_config/server/types.ts`. Below are the functions inside the interface. + +``` + getConfig(): Promise>; + + getEntityConfig(entity: string): Promise; + + updateEntityConfig(entity: string, newValue: string): Promise; + + deleteEntityConfig(entity: string): Promise; +``` + +Second, this plugin needs to declare `applicationConfig` as its dependency by adding it to `requiredPlugins` in its own `opensearch_dashboards.json`. + +Third, the plugin will define a new type called `AppPluginSetupDependencies` as follows in its own `types.ts`. + +``` +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +} + +``` + +Then the plugin will import the new type `AppPluginSetupDependencies` and add to its own setup input. Below is the skeleton of the class `MyConfigurationClientPlugin`. + +``` +// MyConfigurationClientPlugin + public setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { + + ... + // The function createClient provides an instance of ConfigurationClient which + // could have a underlying DynamoDB or Postgres implementation. + const myConfigurationClient: ConfigurationClient = this.createClient(); + + applicationConfig.registerConfigurationClient(myConfigurationClient); + ... + return {}; + } + +``` + +## Onboarding Configurations + +Since the APIs and interfaces can take an entity, a new use case to this plugin could just pass their entity into the parameters. There is no need to implement new APIs or interfaces. To programmatically call the functions in `ConfigurationClient` from a plugin (the caller plugin), below is the code example. + +Similar to [section](#external-configuration-clients), a new type `AppPluginSetupDependencies` which encapsulates `ApplicationConfigPluginSetup` is needed. Then it can be imported into the `setup` function of the caller plugin. Then the caller plugin will have access to the `getConfigurationClient` and `registerConfigurationClient` exposed by `ApplicationConfigPluginSetup`. + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/application_config/common/index.ts b/src/plugins/application_config/common/index.ts new file mode 100644 index 000000000000..57af4908f4a3 --- /dev/null +++ b/src/plugins/application_config/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'applicationConfig'; +export const PLUGIN_NAME = 'application_config'; diff --git a/src/plugins/application_config/config.ts b/src/plugins/application_config/config.ts new file mode 100644 index 000000000000..4968c8a9a7c7 --- /dev/null +++ b/src/plugins/application_config/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ApplicationConfigSchema = TypeOf; diff --git a/src/plugins/application_config/opensearch_dashboards.json b/src/plugins/application_config/opensearch_dashboards.json new file mode 100644 index 000000000000..728c282a2108 --- /dev/null +++ b/src/plugins/application_config/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "applicationConfig", + "version": "opensearchDashboards", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": false, + "requiredPlugins": [], + "optionalPlugins": [] +} \ No newline at end of file diff --git a/src/plugins/application_config/server/index.ts b/src/plugins/application_config/server/index.ts new file mode 100644 index 000000000000..1ef2bbc3baf9 --- /dev/null +++ b/src/plugins/application_config/server/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { ApplicationConfigSchema, configSchema } from '../config'; +import { ApplicationConfigPlugin } from './plugin'; + +/* +This exports static code and TypeScript types, +as well as, OpenSearch Dashboards Platform `plugin()` initializer. +*/ + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export function plugin(initializerContext: PluginInitializerContext) { + return new ApplicationConfigPlugin(initializerContext); +} + +export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types'; diff --git a/src/plugins/application_config/server/opensearch_config_client.test.ts b/src/plugins/application_config/server/opensearch_config_client.test.ts new file mode 100644 index 000000000000..827d309303cb --- /dev/null +++ b/src/plugins/application_config/server/opensearch_config_client.test.ts @@ -0,0 +1,359 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { OpenSearchConfigurationClient } from './opensearch_config_client'; +import { MockedLogger, loggerMock } from '@osd/logging/target/mocks'; + +const INDEX_NAME = 'test_index'; +const ERROR_MESSAGE = 'Service unavailable'; +const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!'; +const EMPTY_INPUT = ' '; + +describe('OpenSearch Configuration Client', () => { + let logger: MockedLogger; + + beforeEach(() => { + logger = loggerMock.create(); + }); + + describe('getConfig', () => { + it('returns configurations from the index', async () => { + const opensearchClient = { + asInternalUser: { + search: jest.fn().mockImplementation(() => { + return { + body: { + hits: { + hits: [ + { + _id: 'config1', + _source: { + value: 'value1', + }, + }, + { + _id: 'config2', + _source: { + value: 'value2', + }, + }, + ], + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.getConfig(); + + expect(JSON.stringify(value)).toBe(JSON.stringify({ config1: 'value1', config2: 'value2' })); + }); + + it('throws error when opensearch errors happen', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asInternalUser: { + search: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getConfig()).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('getEntityConfig', () => { + it('return configuration value from the document in the index', async () => { + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + return { + body: { + _source: { + value: 'value1', + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.getEntityConfig('config1'); + + expect(value).toBe('value1'); + }); + + it('throws error when input is empty', async () => { + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + return { + body: { + _source: { + value: 'value1', + }, + }, + }; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getEntityConfig(EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when opensearch errors happen', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.getEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('deleteEntityConfig', () => { + it('return deleted entity when opensearch deletes successfully', async () => { + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('throws error when input entity is empty', async () => { + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.deleteEntityConfig(EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('return deleted document entity when deletion fails due to index not found', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: 'index_not_found_exception', + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('return deleted document entity when deletion fails due to document not found', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + result: 'not_found', + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.deleteEntityConfig('config1'); + + expect(value).toBe('config1'); + }); + + it('throws error when opensearch throws error', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + delete: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.deleteEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); + }); + }); + + describe('updateEntityConfig', () => { + it('returns updated value when opensearch updates successfully', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const value = await client.updateEntityConfig('config1', 'newValue1'); + + expect(value).toBe('newValue1'); + }); + + it('throws error when entity is empty ', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig(EMPTY_INPUT, 'newValue1')).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when new value is empty ', async () => { + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + return {}; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig('config1', EMPTY_INPUT)).rejects.toThrowError( + ERROR_MESSSAGE_FOR_EMPTY_INPUT + ); + }); + + it('throws error when opensearch throws error', async () => { + const error = new ResponseError({ + statusCode: 401, + body: { + error: { + type: ERROR_MESSAGE, + }, + }, + warnings: [], + headers: { + 'WWW-Authenticate': 'content', + }, + meta: {} as any, + }); + + const opensearchClient = { + asCurrentUser: { + index: jest.fn().mockImplementation(() => { + throw error; + }), + }, + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + await expect(client.updateEntityConfig('config1', 'newValue1')).rejects.toThrowError( + ERROR_MESSAGE + ); + }); + }); +}); diff --git a/src/plugins/application_config/server/opensearch_config_client.ts b/src/plugins/application_config/server/opensearch_config_client.ts new file mode 100644 index 000000000000..9103919c396f --- /dev/null +++ b/src/plugins/application_config/server/opensearch_config_client.ts @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IScopedClusterClient, Logger } from '../../../../src/core/server'; + +import { ConfigurationClient } from './types'; +import { validate } from './string_utils'; + +export class OpenSearchConfigurationClient implements ConfigurationClient { + private client: IScopedClusterClient; + private configurationIndexName: string; + private readonly logger: Logger; + + constructor( + scopedClusterClient: IScopedClusterClient, + configurationIndexName: string, + logger: Logger + ) { + this.client = scopedClusterClient; + this.configurationIndexName = configurationIndexName; + this.logger = logger; + } + + async getEntityConfig(entity: string) { + const entityValidated = validate(entity, this.logger); + + try { + const data = await this.client.asInternalUser.get({ + index: this.configurationIndexName, + id: entityValidated, + }); + + return data?.body?._source?.value || ''; + } catch (e) { + const errorMessage = `Failed to get entity ${entityValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async updateEntityConfig(entity: string, newValue: string) { + const entityValidated = validate(entity, this.logger); + const newValueValidated = validate(newValue, this.logger); + + try { + await this.client.asCurrentUser.index({ + index: this.configurationIndexName, + id: entityValidated, + body: { + value: newValueValidated, + }, + }); + + return newValueValidated; + } catch (e) { + const errorMessage = `Failed to update entity ${entityValidated} with newValue ${newValueValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async deleteEntityConfig(entity: string) { + const entityValidated = validate(entity, this.logger); + + try { + await this.client.asCurrentUser.delete({ + index: this.configurationIndexName, + id: entityValidated, + }); + + return entityValidated; + } catch (e) { + if (e?.body?.error?.type === 'index_not_found_exception') { + this.logger.info('Attemp to delete a not found index.'); + return entityValidated; + } + + if (e?.body?.result === 'not_found') { + this.logger.info('Attemp to delete a not found document.'); + return entityValidated; + } + + const errorMessage = `Failed to delete entity ${entityValidated} due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + async getConfig(): Promise> { + try { + const data = await this.client.asInternalUser.search({ + index: this.configurationIndexName, + }); + + return this.transformIndexSearchResponse(data.body.hits.hits); + } catch (e) { + const errorMessage = `Failed to call getConfig due to error ${e}`; + + this.logger.error(errorMessage); + + throw e; + } + } + + transformIndexSearchResponse(hits): Map { + const configurations = {}; + + for (let i = 0; i < hits.length; i++) { + const doc = hits[i]; + configurations[doc._id] = doc?._source?.value; + } + + return configurations; + } +} diff --git a/src/plugins/application_config/server/plugin.test.ts b/src/plugins/application_config/server/plugin.test.ts new file mode 100644 index 000000000000..e1ac45444c14 --- /dev/null +++ b/src/plugins/application_config/server/plugin.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { of } from 'rxjs'; +import { ApplicationConfigPlugin } from './plugin'; +import { ConfigurationClient } from './types'; + +describe('application config plugin', () => { + it('throws error when trying to register twice', async () => { + const initializerContext = { + logger: { + get: jest.fn().mockImplementation(() => { + return { + info: jest.fn(), + error: jest.fn(), + }; + }), + }, + config: { + legacy: { + globalConfig$: of({ + opensearchDashboards: { + configIndex: '.osd_test', + }, + }), + }, + }, + }; + + const plugin = new ApplicationConfigPlugin(initializerContext); + + const coreSetup = { + http: { + createRouter: jest.fn().mockImplementation(() => { + return { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + }; + }), + }, + }; + + const setup = await plugin.setup(coreSetup); + + const client1: ConfigurationClient = { + getConfig: jest.fn(), + getEntityConfig: jest.fn(), + updateEntityConfig: jest.fn(), + deleteEntityConfig: jest.fn(), + }; + + setup.registerConfigurationClient(client1); + + const scopedClient = {}; + expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + + const client2: ConfigurationClient = { + getConfig: jest.fn(), + getEntityConfig: jest.fn(), + updateEntityConfig: jest.fn(), + deleteEntityConfig: jest.fn(), + }; + + // call the register function again + const secondCall = () => setup.registerConfigurationClient(client2); + + expect(secondCall).toThrowError( + 'Configuration client is already registered! Cannot register again!' + ); + + expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + }); +}); diff --git a/src/plugins/application_config/server/plugin.ts b/src/plugins/application_config/server/plugin.ts new file mode 100644 index 000000000000..d0bd2ab42270 --- /dev/null +++ b/src/plugins/application_config/server/plugin.ts @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + IScopedClusterClient, + SharedGlobalConfig, +} from '../../../core/server'; + +import { + ApplicationConfigPluginSetup, + ApplicationConfigPluginStart, + ConfigurationClient, +} from './types'; +import { defineRoutes } from './routes'; +import { OpenSearchConfigurationClient } from './opensearch_config_client'; + +export class ApplicationConfigPlugin + implements Plugin { + private readonly logger: Logger; + private readonly config$: Observable; + + private configurationClient: ConfigurationClient; + private configurationIndexName: string; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.config$ = initializerContext.config.legacy.globalConfig$; + this.configurationIndexName = ''; + } + + private registerConfigurationClient(configurationClient: ConfigurationClient) { + this.logger.info('Register a configuration client.'); + + if (this.configurationClient) { + const errorMessage = 'Configuration client is already registered! Cannot register again!'; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + this.configurationClient = configurationClient; + } + + private getConfigurationClient(scopedClusterClient: IScopedClusterClient): ConfigurationClient { + if (this.configurationClient) { + return this.configurationClient; + } + + const openSearchConfigurationClient = new OpenSearchConfigurationClient( + scopedClusterClient, + this.configurationIndexName, + this.logger + ); + + return openSearchConfigurationClient; + } + + public async setup(core: CoreSetup) { + const router = core.http.createRouter(); + + const config = await this.config$.pipe(first()).toPromise(); + + this.configurationIndexName = config.opensearchDashboards.configIndex; + + // Register server side APIs + defineRoutes(router, this.getConfigurationClient.bind(this), this.logger); + + return { + getConfigurationClient: this.getConfigurationClient.bind(this), + registerConfigurationClient: this.registerConfigurationClient.bind(this), + }; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/application_config/server/routes/index.test.ts b/src/plugins/application_config/server/routes/index.test.ts new file mode 100644 index 000000000000..086baa646d2b --- /dev/null +++ b/src/plugins/application_config/server/routes/index.test.ts @@ -0,0 +1,353 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock } from '../../../../core/server/mocks'; +import { loggerMock } from '@osd/logging/target/mocks'; +import { + defineRoutes, + handleDeleteEntityConfig, + handleGetConfig, + handleGetEntityConfig, + handleUpdateEntityConfig, +} from '.'; + +const ERROR_MESSAGE = 'Service unavailable'; + +const ERROR_RESPONSE = { + statusCode: 500, +}; + +const ENTITY_NAME = 'config1'; +const ENTITY_VALUE = 'value1'; +const ENTITY_NEW_VALUE = 'newValue1'; + +describe('application config routes', () => { + describe('defineRoutes', () => { + it('check route paths are defined', () => { + const router = httpServiceMock.createRouter(); + const configurationClient = { + existsCspRules: jest.fn().mockReturnValue(true), + getCspRules: jest.fn().mockReturnValue(''), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const logger = loggerMock.create(); + + defineRoutes(router, getConfigurationClient, logger); + + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig', + }), + expect.any(Function) + ); + + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + + expect(router.post).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + + expect(router.delete).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/appconfig/{entity}', + }), + expect.any(Function) + ); + }); + }); + + describe('handleGetConfig', () => { + it('returns configurations when client returns', async () => { + const configurations = { + config1: 'value1', + config2: 'value2', + }; + + const client = { + getConfig: jest.fn().mockReturnValue(configurations), + }; + + const okResponse = { + statusCode: 200, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetConfig(client, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(response.ok).toBeCalledWith({ + body: { + value: configurations, + }, + }); + }); + + it('return error response when client throws error', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + getConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetConfig(client, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.getConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleGetEntityConfig', () => { + it('returns value when client returns value', async () => { + const client = { + getEntityConfig: jest.fn().mockReturnValue(ENTITY_VALUE), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(response.ok).toBeCalledWith({ + body: { + value: ENTITY_VALUE, + }, + }); + }); + + it('return error response when client throws error', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.getEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleUpdateEntityConfig', () => { + it('return success when client succeeds', async () => { + const client = { + updateEntityConfig: jest.fn().mockReturnValue(ENTITY_NEW_VALUE), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + body: { + newValue: ENTITY_NEW_VALUE, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(client.updateEntityConfig).toBeCalledTimes(1); + + expect(response.ok).toBeCalledWith({ + body: { + newValue: ENTITY_NEW_VALUE, + }, + }); + + expect(logger.error).not.toBeCalled(); + }); + + it('return error response when client fails', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + updateEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + body: { + newValue: ENTITY_NEW_VALUE, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.updateEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); + + describe('handleDeleteEntityConfig', () => { + it('returns successful response when client succeeds', async () => { + const client = { + deleteEntityConfig: jest.fn().mockReturnValue(ENTITY_NAME), + }; + + const okResponse = { + statusCode: 200, + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + ok: jest.fn().mockReturnValue(okResponse), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(okResponse); + + expect(client.deleteEntityConfig).toBeCalledTimes(1); + + expect(response.ok).toBeCalledWith({ + body: { + deletedEntity: ENTITY_NAME, + }, + }); + + expect(logger.error).not.toBeCalled(); + }); + + it('return error response when client fails', async () => { + const error = new Error(ERROR_MESSAGE); + + const client = { + deleteEntityConfig: jest.fn().mockImplementation(() => { + throw error; + }), + }; + + const request = { + params: { + entity: ENTITY_NAME, + }, + }; + + const response = { + customError: jest.fn().mockReturnValue(ERROR_RESPONSE), + }; + + const logger = loggerMock.create(); + + const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + + expect(returnedResponse).toBe(ERROR_RESPONSE); + + expect(client.deleteEntityConfig).toBeCalledTimes(1); + + expect(response.customError).toBeCalledWith({ + body: error, + statusCode: 500, + }); + + expect(logger.error).toBeCalledWith(error); + }); + }); +}); diff --git a/src/plugins/application_config/server/routes/index.ts b/src/plugins/application_config/server/routes/index.ts new file mode 100644 index 000000000000..7a059bf52f35 --- /dev/null +++ b/src/plugins/application_config/server/routes/index.ts @@ -0,0 +1,162 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { + IRouter, + IScopedClusterClient, + Logger, + OpenSearchDashboardsRequest, + OpenSearchDashboardsResponseFactory, +} from '../../../../core/server'; +import { ConfigurationClient } from '../types'; + +export function defineRoutes( + router: IRouter, + getConfigurationClient: (configurationClient: IScopedClusterClient) => ConfigurationClient, + logger: Logger +) { + router.get( + { + path: '/api/appconfig', + validate: false, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleGetConfig(client, response, logger); + } + ); + router.get( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleGetEntityConfig(client, request, response, logger); + } + ); + router.post( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + body: schema.object({ + newValue: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleUpdateEntityConfig(client, request, response, logger); + } + ); + router.delete( + { + path: '/api/appconfig/{entity}', + validate: { + params: schema.object({ + entity: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = getConfigurationClient(context.core.opensearch.client); + + return await handleDeleteEntityConfig(client, request, response, logger); + } + ); +} + +export async function handleGetEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.getEntityConfig(request.params.entity); + return response.ok({ + body: { + value: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleUpdateEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.updateEntityConfig(request.params.entity, request.body.newValue); + return response.ok({ + body: { + newValue: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleDeleteEntityConfig( + client: ConfigurationClient, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.deleteEntityConfig(request.params.entity); + return response.ok({ + body: { + deletedEntity: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +export async function handleGetConfig( + client: ConfigurationClient, + response: OpenSearchDashboardsResponseFactory, + logger: Logger +) { + try { + const result = await client.getConfig(); + return response.ok({ + body: { + value: result, + }, + }); + } catch (e) { + logger.error(e); + return errorResponse(response, e); + } +} + +function errorResponse(response: OpenSearchDashboardsResponseFactory, error: any) { + return response.customError({ + statusCode: error?.statusCode || 500, + body: error, + }); +} diff --git a/src/plugins/application_config/server/string_utils.test.ts b/src/plugins/application_config/server/string_utils.test.ts new file mode 100644 index 000000000000..2baf765a5bc0 --- /dev/null +++ b/src/plugins/application_config/server/string_utils.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { validate } from './string_utils'; + +describe('application config string utils', () => { + it('returns input when input is not empty and no prefix or suffix whitespaces', () => { + const logger = { + error: jest.fn(), + }; + + const input = 'abc'; + + const validatedInput = validate(input, logger); + + expect(validatedInput).toBe(input); + expect(logger.error).not.toBeCalled(); + }); + + it('returns trimmed input when input is not empty and prefix or suffix whitespaces', () => { + const logger = { + error: jest.fn(), + }; + + const input = ' abc '; + + const validatedInput = validate(input, logger); + + expect(validatedInput).toBe('abc'); + expect(logger.error).not.toBeCalled(); + }); + + it('throws error when input is empty', () => { + const logger = { + error: jest.fn(), + }; + + expect(() => { + validate(' ', logger); + }).toThrowError('Input cannot be empty!'); + }); +}); diff --git a/src/plugins/application_config/server/string_utils.ts b/src/plugins/application_config/server/string_utils.ts new file mode 100644 index 000000000000..34e9842b7b6d --- /dev/null +++ b/src/plugins/application_config/server/string_utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from 'src/core/server'; + +const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!'; +const ERROR_FOR_EMPTY_INPUT = new Error(ERROR_MESSSAGE_FOR_EMPTY_INPUT); + +function isEmpty(input: string): boolean { + if (!input) { + return true; + } + + return !input.trim(); +} + +export function validate(input: string, logger: Logger): string { + if (isEmpty(input)) { + logger.error(ERROR_MESSSAGE_FOR_EMPTY_INPUT); + throw ERROR_FOR_EMPTY_INPUT; + } + + return input.trim(); +} diff --git a/src/plugins/application_config/server/types.ts b/src/plugins/application_config/server/types.ts new file mode 100644 index 000000000000..49fc11d99c53 --- /dev/null +++ b/src/plugins/application_config/server/types.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IScopedClusterClient } from 'src/core/server'; + +export interface ApplicationConfigPluginSetup { + getConfigurationClient: (inputOpenSearchClient: IScopedClusterClient) => ConfigurationClient; + registerConfigurationClient: (inputConfigurationClient: ConfigurationClient) => void; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ApplicationConfigPluginStart {} + +/** + * The interface defines the operations against the application configurations at both entity level and whole level. + * + */ +export interface ConfigurationClient { + /** + * Get all the configurations. + * + * @param {array} array of connections + * @returns {ConnectionPool} + */ + getConfig(): Promise>; + + /** + * Get the value for the input entity. + * + * @param {entity} name of the entity + * @returns {string} value of the entity + */ + getEntityConfig(entity: string): Promise; + + /** + * Update the input entity with a new value. + * + * @param {entity} name of the entity + * @param {newValue} new configuration value of the entity + * @returns {string} updated configuration value of the entity + */ + updateEntityConfig(entity: string, newValue: string): Promise; + + /** + * Delete the input entity from configurations. + * + * @param {entity} name of the entity + * @returns {string} name of the deleted entity + */ + deleteEntityConfig(entity: string): Promise; +} diff --git a/src/plugins/data/common/index_patterns/lib/get_title.test.ts b/src/plugins/data/common/index_patterns/lib/get_title.test.ts new file mode 100644 index 000000000000..e826bdb5a5e8 --- /dev/null +++ b/src/plugins/data/common/index_patterns/lib/get_title.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { getTitle } from './get_title'; + +describe('test getTitle', () => { + let client: SavedObjectsClientContract; + + it('with dataSourceId match', async () => { + const dataSourceIdToTitle = new Map(); + dataSourceIdToTitle.set('dataSourceId', 'dataSourceTitle'); + client = { + get: jest.fn().mockResolvedValue({ + attributes: { title: 'indexTitle' }, + references: [{ type: 'data-source', id: 'dataSourceId' }], + }), + } as any; + const title = await getTitle(client, 'indexPatternId', dataSourceIdToTitle); + expect(title).toEqual('dataSourceTitle::indexTitle'); + }); + + it('with no dataSourceId match and error to get data source', async () => { + const dataSourceIdToTitle = new Map(); + client = { + get: jest + .fn() + .mockResolvedValueOnce({ + attributes: { title: 'indexTitle' }, + references: [{ type: 'data-source', id: 'dataSourceId' }], + }) + .mockRejectedValue(new Error('error')), + } as any; + const title = await getTitle(client, 'indexPatternId', dataSourceIdToTitle); + expect(title).toEqual('dataSourceId::indexTitle'); + }); + + it('with no dataSourceId match and success to get data source', async () => { + const dataSourceIdToTitle = new Map(); + client = { + get: jest + .fn() + .mockResolvedValueOnce({ + attributes: { title: 'indexTitle' }, + references: [{ type: 'data-source', id: 'dataSourceId' }], + }) + .mockResolvedValue({ attributes: { title: 'acquiredDataSourceTitle' } }), + } as any; + const title = await getTitle(client, 'indexPatternId', dataSourceIdToTitle); + expect(title).toEqual('acquiredDataSourceTitle::indexTitle'); + }); +}); diff --git a/src/plugins/data/common/index_patterns/lib/get_title.ts b/src/plugins/data/common/index_patterns/lib/get_title.ts index c8677a9a7984..15e84eee5827 100644 --- a/src/plugins/data/common/index_patterns/lib/get_title.ts +++ b/src/plugins/data/common/index_patterns/lib/get_title.ts @@ -28,17 +28,39 @@ * under the License. */ +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public'; +import { + concatDataSourceWithIndexPattern, + getIndexPatternTitle, + getDataSourceReference, +} from '../utils'; export async function getTitle( client: SavedObjectsClientContract, - indexPatternId: string -): Promise> { + indexPatternId: string, + dataSourceIdToTitle: Map +): Promise { const savedObject = (await client.get('index-pattern', indexPatternId)) as SimpleSavedObject; if (savedObject.error) { throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); } - return savedObject.attributes.title; + const dataSourceReference = getDataSourceReference(savedObject.references); + + if (dataSourceReference) { + const dataSourceId = dataSourceReference.id; + if (dataSourceIdToTitle.has(dataSourceId)) { + return concatDataSourceWithIndexPattern( + dataSourceIdToTitle.get(dataSourceId)!, + savedObject.attributes.title + ); + } + } + + const getDataSource = async (id: string) => + await client.get('data-source', id); + + return getIndexPatternTitle(savedObject.attributes.title, savedObject.references, getDataSource); } diff --git a/src/plugins/data/common/index_patterns/utils.test.ts b/src/plugins/data/common/index_patterns/utils.test.ts index 1c1b56df5ba0..1157bfb1ac5d 100644 --- a/src/plugins/data/common/index_patterns/utils.test.ts +++ b/src/plugins/data/common/index_patterns/utils.test.ts @@ -72,7 +72,7 @@ describe('test getIndexPatternTitle', () => { referencesMock, getDataSourceMock ); - expect(res).toEqual('dataSourceMockTitle.indexPatternMockTitle'); + expect(res).toEqual('dataSourceMockTitle::indexPatternMockTitle'); }); test('getIndexPatternTitle should return index pattern title, when index-pattern is not referenced to any datasource', async () => { @@ -87,6 +87,6 @@ describe('test getIndexPatternTitle', () => { referencesMock, getDataSourceMock ); - expect(res).toEqual('dataSourceId.indexPatternMockTitle'); + expect(res).toEqual('dataSourceId::indexPatternMockTitle'); }); }); diff --git a/src/plugins/data/common/index_patterns/utils.ts b/src/plugins/data/common/index_patterns/utils.ts index bba25bfd6df8..0da34ebb7fa6 100644 --- a/src/plugins/data/common/index_patterns/utils.ts +++ b/src/plugins/data/common/index_patterns/utils.ts @@ -82,9 +82,8 @@ export const getIndexPatternTitle = async ( references: SavedObjectReference[], getDataSource: (id: string) => Promise> ): Promise => { - const DATA_SOURCE_INDEX_PATTERN_DELIMITER = '.'; let dataSourceTitle; - const dataSourceReference = references.find((ref) => ref.type === 'data-source'); + const dataSourceReference = getDataSourceReference(references); // If an index-pattern references datasource, prepend data source name with index pattern name for display purpose if (dataSourceReference) { @@ -99,10 +98,22 @@ export const getIndexPatternTitle = async ( // use datasource id as title when failing to fetch datasource dataSourceTitle = dataSourceId; } - - return dataSourceTitle.concat(DATA_SOURCE_INDEX_PATTERN_DELIMITER).concat(indexPatternTitle); + return concatDataSourceWithIndexPattern(dataSourceTitle, indexPatternTitle); } else { // if index pattern doesn't reference datasource, return as it is. return indexPatternTitle; } }; + +export const concatDataSourceWithIndexPattern = ( + dataSourceTitle: string, + indexPatternTitle: string +) => { + const DATA_SOURCE_INDEX_PATTERN_DELIMITER = '::'; + + return dataSourceTitle.concat(DATA_SOURCE_INDEX_PATTERN_DELIMITER).concat(indexPatternTitle); +}; + +export const getDataSourceReference = (references: SavedObjectReference[]) => { + return references.find((ref) => ref.type === 'data-source'); +}; diff --git a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.test.tsx b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.test.tsx index 3e6c99e420d6..63c2437cc7b3 100644 --- a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.test.tsx +++ b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.test.tsx @@ -181,4 +181,47 @@ describe('DataSourceSelectable', () => { ]; expect(optionTexts).toEqual(expectedIndexPatternSortedOrder); }); + + it('should allow display and selection of duplicated index patterns based on unique key', async () => { + const mockDataSourceOptionListWithDuplicates = [ + { + label: 'Index patterns', + options: [ + { label: 'duplicate-index-pattern', key: 'unique-key-1' }, + { label: 'unique-index-pattern-1', key: 'unique-key-2' }, + { label: 'duplicate-index-pattern', key: 'unique-key-3' }, + { label: 'unique-index-pattern-2', key: 'unique-key-4' }, + ], + }, + ] as any; + + const handleSelect = jest.fn(); + + render( + + ); + + const button = screen.getByLabelText('Open list of options'); + fireEvent.click(button); + + const optionsToSelect = screen.getAllByText('duplicate-index-pattern'); + fireEvent.click(optionsToSelect[1]); + + expect(handleSelect).toHaveBeenCalledWith( + expect.objectContaining([{ key: 'unique-key-3', label: 'duplicate-index-pattern' }]) + ); + }); }); diff --git a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx index 1c6876815a0e..88abe18aa143 100644 --- a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx +++ b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx @@ -52,6 +52,7 @@ export const getSourceOptions = (dataSource: DataSourceType, dataSet: DataSetTyp ...optionContent, label: dataSet.title, value: dataSet.id, + key: dataSet.id, }; } return { diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx index bcc56713f0df..228c803b0f97 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.tsx @@ -36,6 +36,10 @@ import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui'; import { SavedObjectsClientContract, SimpleSavedObject } from 'src/core/public'; import { getTitle } from '../../../common/index_patterns/lib'; +import { + getDataSourceReference, + concatDataSourceWithIndexPattern, +} from '../../../common/index_patterns/utils'; export type IndexPatternSelectProps = Required< Omit, 'isLoading' | 'onSearchChange' | 'options' | 'selectedOptions'>, @@ -52,6 +56,7 @@ interface IndexPatternSelectState { options: []; selectedIndexPattern: { value: string; label: string } | undefined; searchValue: string | undefined; + dataSourceIdToTitle: Map; } const getIndexPatterns = async ( @@ -80,6 +85,7 @@ export default class IndexPatternSelect extends Component = []; + savedObjects.map((indexPatternSavedObject: SimpleSavedObject) => { + const dataSourceReference = getDataSourceReference(indexPatternSavedObject.references); + if (dataSourceReference && !this.state.dataSourceIdToTitle.has(dataSourceReference.id)) { + dataSourcesToFetch.push({ type: 'data-source', id: dataSourceReference.id }); + } + }); + + const dataSourceIdToTitleToUpdate = new Map(); + + if (dataSourcesToFetch.length > 0) { + const resp = await savedObjectsClient.bulkGet(dataSourcesToFetch); + resp.savedObjects.map((dataSourceSavedObject: SimpleSavedObject) => { + dataSourceIdToTitleToUpdate.set( + dataSourceSavedObject.id, + dataSourceSavedObject.attributes.title + ); + }); + } + const options = savedObjects.map((indexPatternSavedObject: SimpleSavedObject) => { + const dataSourceReference = getDataSourceReference(indexPatternSavedObject.references); + if (dataSourceReference) { + const dataSourceTitle = + this.state.dataSourceIdToTitle.get(dataSourceReference.id) || + dataSourceIdToTitleToUpdate.get(dataSourceReference.id) || + dataSourceReference.id; + return { + label: `${concatDataSourceWithIndexPattern( + dataSourceTitle, + indexPatternSavedObject.attributes.title + )}`, + value: indexPatternSavedObject.id, + }; + } return { label: indexPatternSavedObject.attributes.title, value: indexPatternSavedObject.id, }; }); - this.setState({ - isLoading: false, - options, - }); + + if (dataSourceIdToTitleToUpdate.size > 0) { + const mergedDataSourceIdToTitle = new Map(); + this.state.dataSourceIdToTitle.forEach((k, v) => { + mergedDataSourceIdToTitle.set(k, v); + }); + dataSourceIdToTitleToUpdate.forEach((k, v) => { + mergedDataSourceIdToTitle.set(k, v); + }); + this.setState({ + dataSourceIdToTitle: mergedDataSourceIdToTitle, + isLoading: false, + options, + }); + } else { + this.setState({ + isLoading: false, + options, + }); + } if (onNoIndexPatterns && searchValue === '' && options.length === 0) { onNoIndexPatterns(); diff --git a/src/plugins/data/server/search/opensearch_search/decide_client.ts b/src/plugins/data/server/search/opensearch_search/decide_client.ts index 2ff2339add44..41e0d5c16277 100644 --- a/src/plugins/data/server/search/opensearch_search/decide_client.ts +++ b/src/plugins/data/server/search/opensearch_search/decide_client.ts @@ -11,12 +11,11 @@ export const decideClient = async ( request: IOpenSearchSearchRequest, withLongNumeralsSupport: boolean = false ): Promise => { - // if data source feature is disabled, return default opensearch client of current user - const client = - request.dataSourceId && context.dataSource - ? await context.dataSource.opensearch.getClient(request.dataSourceId) - : withLongNumeralsSupport - ? context.core.opensearch.client.asCurrentUserWithLongNumeralsSupport - : context.core.opensearch.client.asCurrentUser; - return client; + const defaultOpenSearchClient = withLongNumeralsSupport + ? context.core.opensearch.client.asCurrentUserWithLongNumeralsSupport + : context.core.opensearch.client.asCurrentUser; + + return request.dataSourceId && context.dataSource + ? await context.dataSource.opensearch.getClient(request.dataSourceId) + : defaultOpenSearchClient; }; diff --git a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.test.ts b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.test.ts index 39c367a04a41..ae6e1746dab6 100644 --- a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.test.ts +++ b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.test.ts @@ -29,15 +29,47 @@ */ import { RequestHandlerContext } from '../../../../../core/server'; -import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks'; +import { + opensearchServiceMock, + pluginInitializerContextConfigMock, +} from '../../../../../core/server/mocks'; import { opensearchSearchStrategyProvider } from './opensearch_search_strategy'; import { DataSourceError } from '../../../../data_source/server/lib/error'; import { DataSourcePluginSetup } from '../../../../data_source/server'; +import { SearchUsage } from '../collectors'; describe('OpenSearch search strategy', () => { const mockLogger: any = { debug: () => {}, }; + const mockSearchUsage: SearchUsage = { + trackError(): Promise { + return Promise.resolve(undefined); + }, + trackSuccess(duration: number): Promise { + return Promise.resolve(undefined); + }, + }; + const mockDataSourcePluginSetupWithDataSourceEnabled: DataSourcePluginSetup = { + createDataSourceError(err: any): DataSourceError { + return new DataSourceError({}); + }, + dataSourceEnabled: jest.fn(() => true), + registerCredentialProvider: jest.fn(), + registerCustomApiSchema(schema: any): void { + throw new Error('Function not implemented.'); + }, + }; + const mockDataSourcePluginSetupWithDataSourceDisabled: DataSourcePluginSetup = { + createDataSourceError(err: any): DataSourceError { + return new DataSourceError({}); + }, + dataSourceEnabled: jest.fn(() => false), + registerCredentialProvider: jest.fn(), + registerCustomApiSchema(schema: any): void { + throw new Error('Function not implemented.'); + }, + }; const body = { body: { _shards: { @@ -50,6 +82,7 @@ describe('OpenSearch search strategy', () => { }; const mockOpenSearchApiCaller = jest.fn().mockResolvedValue(body); const mockDataSourceApiCaller = jest.fn().mockResolvedValue(body); + const mockOpenSearchApiCallerWithLongNumeralsSupport = jest.fn().mockResolvedValue(body); const dataSourceId = 'test-data-source-id'; const mockDataSourceContext = { dataSource: { @@ -67,7 +100,14 @@ describe('OpenSearch search strategy', () => { get: () => {}, }, }, - opensearch: { client: { asCurrentUser: { search: mockOpenSearchApiCaller } } }, + opensearch: { + client: { + asCurrentUser: { search: mockOpenSearchApiCaller }, + asCurrentUserWithLongNumeralsSupport: { + search: mockOpenSearchApiCallerWithLongNumeralsSupport, + }, + }, + }, }, }; const mockDataSourceEnabledContext = { @@ -131,8 +171,23 @@ describe('OpenSearch search strategy', () => { expect(response).toHaveProperty('rawResponse'); }); - it('dataSource enabled, send request with dataSourceId get data source client', async () => { - const opensearchSearch = await opensearchSearchStrategyProvider(mockConfig$, mockLogger); + it('dataSource enabled, config host exist, send request with dataSourceId should get data source client', async () => { + const mockOpenSearchServiceSetup = opensearchServiceMock.createSetup(); + mockOpenSearchServiceSetup.legacy.client = { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + config: { + hosts: ['some host'], + }, + }; + + const opensearchSearch = opensearchSearchStrategyProvider( + mockConfig$, + mockLogger, + mockSearchUsage, + mockDataSourcePluginSetupWithDataSourceEnabled, + mockOpenSearchServiceSetup + ); await opensearchSearch.search( (mockDataSourceEnabledContext as unknown) as RequestHandlerContext, @@ -140,11 +195,86 @@ describe('OpenSearch search strategy', () => { dataSourceId, } ); + expect(mockDataSourceApiCaller).toBeCalled(); expect(mockOpenSearchApiCaller).not.toBeCalled(); }); - it('dataSource disabled, send request with dataSourceId get default client', async () => { + it('dataSource enabled, config host exist, send request without dataSourceId should get default client', async () => { + const mockOpenSearchServiceSetup = opensearchServiceMock.createSetup(); + mockOpenSearchServiceSetup.legacy.client = { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + config: { + hosts: ['some host'], + }, + }; + + const opensearchSearch = opensearchSearchStrategyProvider( + mockConfig$, + mockLogger, + mockSearchUsage, + mockDataSourcePluginSetupWithDataSourceEnabled, + mockOpenSearchServiceSetup + ); + + const dataSourceIdToBeTested = [undefined, '']; + + dataSourceIdToBeTested.forEach(async (id) => { + const testRequest = id === undefined ? {} : { dataSourceId: id }; + + await opensearchSearch.search( + (mockDataSourceEnabledContext as unknown) as RequestHandlerContext, + testRequest + ); + expect(mockOpenSearchApiCaller).toBeCalled(); + expect(mockDataSourceApiCaller).not.toBeCalled(); + }); + }); + + it('dataSource enabled, config host is empty / undefined, send request with / without dataSourceId should both throw DataSourceError exception', async () => { + const hostsTobeTested = [undefined, []]; + const dataSourceIdToBeTested = [undefined, '', dataSourceId]; + + hostsTobeTested.forEach((host) => { + const mockOpenSearchServiceSetup = opensearchServiceMock.createSetup(); + + if (host !== undefined) { + mockOpenSearchServiceSetup.legacy.client = { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + config: { + hosts: [], + }, + }; + } + + dataSourceIdToBeTested.forEach(async (id) => { + const testRequest = id === undefined ? {} : { dataSourceId: id }; + + try { + const opensearchSearch = opensearchSearchStrategyProvider( + mockConfig$, + mockLogger, + mockSearchUsage, + mockDataSourcePluginSetupWithDataSourceEnabled, + mockOpenSearchServiceSetup + ); + + await opensearchSearch.search( + (mockDataSourceEnabledContext as unknown) as RequestHandlerContext, + testRequest + ); + } catch (e) { + expect(e).toBeTruthy(); + expect(e).toBeInstanceOf(DataSourceError); + expect(e.statusCode).toEqual(400); + } + }); + }); + }); + + it('dataSource disabled, send request with dataSourceId should get default client', async () => { const opensearchSearch = await opensearchSearchStrategyProvider(mockConfig$, mockLogger); await opensearchSearch.search((mockContext as unknown) as RequestHandlerContext, { @@ -154,11 +284,40 @@ describe('OpenSearch search strategy', () => { expect(mockDataSourceApiCaller).not.toBeCalled(); }); - it('dataSource enabled, send request without dataSourceId get default client', async () => { + it('dataSource disabled, send request without dataSourceId should get default client', async () => { const opensearchSearch = await opensearchSearchStrategyProvider(mockConfig$, mockLogger); - await opensearchSearch.search((mockContext as unknown) as RequestHandlerContext, {}); - expect(mockOpenSearchApiCaller).toBeCalled(); - expect(mockDataSourceApiCaller).not.toBeCalled(); + const dataSourceIdToBeTested = [undefined, '']; + + for (const testDataSourceId of dataSourceIdToBeTested) { + await opensearchSearch.search((mockContext as unknown) as RequestHandlerContext, { + dataSourceId: testDataSourceId, + }); + expect(mockOpenSearchApiCaller).toBeCalled(); + expect(mockDataSourceApiCaller).not.toBeCalled(); + } + }); + + it('dataSource disabled and longNumeralsSupported, send request without dataSourceId should get longNumeralsSupport client', async () => { + const mockOpenSearchServiceSetup = opensearchServiceMock.createSetup(); + const opensearchSearch = await opensearchSearchStrategyProvider( + mockConfig$, + mockLogger, + mockSearchUsage, + mockDataSourcePluginSetupWithDataSourceDisabled, + mockOpenSearchServiceSetup, + true + ); + + const dataSourceIdToBeTested = [undefined, '']; + + for (const testDataSourceId of dataSourceIdToBeTested) { + await opensearchSearch.search((mockContext as unknown) as RequestHandlerContext, { + dataSourceId: testDataSourceId, + }); + expect(mockOpenSearchApiCallerWithLongNumeralsSupport).toBeCalled(); + expect(mockOpenSearchApiCaller).not.toBeCalled(); + expect(mockDataSourceApiCaller).not.toBeCalled(); + } }); }); diff --git a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts index 5eb290517792..fa1b3e4da94c 100644 --- a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts +++ b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts @@ -29,7 +29,7 @@ */ import { first } from 'rxjs/operators'; -import { SharedGlobalConfig, Logger } from 'opensearch-dashboards/server'; +import { SharedGlobalConfig, Logger, OpenSearchServiceSetup } from 'opensearch-dashboards/server'; import { SearchResponse } from 'elasticsearch'; import { Observable } from 'rxjs'; import { ApiResponse } from '@opensearch-project/opensearch'; @@ -50,6 +50,7 @@ export const opensearchSearchStrategyProvider = ( logger: Logger, usage?: SearchUsage, dataSource?: DataSourcePluginSetup, + openSearchServiceSetup?: OpenSearchServiceSetup, withLongNumeralsSupport?: boolean ): ISearchStrategy => { return { @@ -73,6 +74,13 @@ export const opensearchSearchStrategyProvider = ( }); try { + const isOpenSearchHostsEmpty = + openSearchServiceSetup?.legacy?.client?.config?.hosts?.length === 0; + + if (dataSource?.dataSourceEnabled() && isOpenSearchHostsEmpty && !request.dataSourceId) { + throw new Error(`Data source id is required when no openseach hosts config provided`); + } + const client = await decideClient(context, request, withLongNumeralsSupport); const promise = shimAbortSignal(client.search(params), options?.abortSignal); @@ -92,7 +100,7 @@ export const opensearchSearchStrategyProvider = ( } catch (e) { if (usage) usage.trackError(); - if (dataSource && request.dataSourceId) { + if (dataSource?.dataSourceEnabled()) { throw dataSource.createDataSourceError(e); } throw e; diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index feb1a3157794..b955596922a0 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -130,7 +130,8 @@ export class SearchService implements Plugin { this.initializerContext.config.legacy.globalConfig$, this.logger, usage, - dataSource + dataSource, + core.opensearch ) ); @@ -141,6 +142,7 @@ export class SearchService implements Plugin { this.logger, usage, dataSource, + core.opensearch, true ) ); diff --git a/src/plugins/data_source/common/data_sources/types.ts b/src/plugins/data_source/common/data_sources/types.ts index d30e5ee710c8..38c14d18ccc4 100644 --- a/src/plugins/data_source/common/data_sources/types.ts +++ b/src/plugins/data_source/common/data_sources/types.ts @@ -14,6 +14,7 @@ export interface DataSourceAttributes extends SavedObjectAttributes { credentials: UsernamePasswordTypedContent | SigV4Content | undefined | AuthTypeContent; }; lastUpdatedTime?: string; + name: AuthType | string; } export interface AuthTypeContent { @@ -30,6 +31,7 @@ export interface SigV4Content extends SavedObjectAttributes { secretKey: string; region: string; service?: SigV4ServiceName; + sessionToken?: string; } export interface UsernamePasswordTypedContent extends SavedObjectAttributes { diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts index d5412d32f0ff..50013537b127 100644 --- a/src/plugins/data_source/config.ts +++ b/src/plugins/data_source/config.ts @@ -39,6 +39,17 @@ export const configSchema = schema.object({ appender: fileAppenderSchema, }), endpointDeniedIPs: schema.maybe(schema.arrayOf(schema.string())), + authTypes: schema.object({ + NoAuthentication: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + UsernamePassword: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + AWSSigV4: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }), }); export type DataSourcePluginConfigType = TypeOf; diff --git a/src/plugins/data_source/public/plugin.ts b/src/plugins/data_source/public/plugin.ts index 65bee912255e..dd2f70a7c193 100644 --- a/src/plugins/data_source/public/plugin.ts +++ b/src/plugins/data_source/public/plugin.ts @@ -22,6 +22,9 @@ export class DataSourcePlugin implements Plugin + (({ + getAllAuthenticationMethods: jest.fn(), + getAuthenticationMethod: jest.fn(), + } as unknown) as jest.Mocked); + +export const authenticationMethodRegisteryMock = { create }; diff --git a/src/plugins/data_source/server/client/configure_client.test.mocks.ts b/src/plugins/data_source/server/client/configure_client.test.mocks.ts index 38a585ff2020..787954a5f97b 100644 --- a/src/plugins/data_source/server/client/configure_client.test.mocks.ts +++ b/src/plugins/data_source/server/client/configure_client.test.mocks.ts @@ -16,3 +16,8 @@ export const parseClientOptionsMock = jest.fn(); jest.doMock('./client_config', () => ({ parseClientOptions: parseClientOptionsMock, })); + +export const authRegistryCredentialProviderMock = jest.fn(); +jest.doMock('../util/credential_provider', () => ({ + authRegistryCredentialProvider: authRegistryCredentialProviderMock, +})); 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 dc0fc2691a83..271ff3f3c05c 100644 --- a/src/plugins/data_source/server/client/configure_client.test.ts +++ b/src/plugins/data_source/server/client/configure_client.test.ts @@ -13,7 +13,11 @@ import { SigV4Content, } from '../../common/data_sources/types'; import { DataSourcePluginConfigType } from '../../config'; -import { ClientMock, parseClientOptionsMock } from './configure_client.test.mocks'; +import { + ClientMock, + parseClientOptionsMock, + authRegistryCredentialProviderMock, +} from './configure_client.test.mocks'; import { OpenSearchClientPoolSetup } from './client_pool'; import { configureClient } from './configure_client'; import { ClientOptions } from '@opensearch-project/opensearch'; @@ -21,8 +25,10 @@ import { ClientOptions } from '@opensearch-project/opensearch'; import { opensearchClientMock } from '../../../../core/server/opensearch/client/mocks'; import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; import { CryptographyServiceSetup } from '../cryptography_service'; -import { DataSourceClientParams } from '../types'; +import { DataSourceClientParams, AuthenticationMethod } from '../types'; import { CustomApiSchemaRegistry } from '../schema_registry'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; @@ -40,6 +46,7 @@ describe('configureClient', () => { let usernamePasswordAuthContent: UsernamePasswordTypedContent; let sigV4AuthContent: SigV4Content; let customApiSchemaRegistry: CustomApiSchemaRegistry; + let authenticationMethodRegistery: jest.Mocked; beforeEach(() => { dsClient = opensearchClientMock.createInternalClient(); @@ -47,6 +54,7 @@ describe('configureClient', () => { savedObjectsMock = savedObjectsClientMock.create(); cryptographyMock = cryptographyServiceSetupMock.create(); customApiSchemaRegistry = new CustomApiSchemaRegistry(); + authenticationMethodRegistery = authenticationMethodRegisteryMock.create(); config = { enabled: true, @@ -242,4 +250,46 @@ describe('configureClient', () => { expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(decodeAndDecryptSpy).toHaveBeenCalledTimes(1); }); + + test('configureClient should retunrn client from authentication registery if method present in registry', async () => { + const name = 'typeA'; + const customAuthContent = { + region: 'us-east-1', + roleARN: 'test-role', + }; + savedObjectsMock.get.mockReset().mockResolvedValueOnce({ + id: DATA_SOURCE_ID, + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes: { + ...dataSourceAttr, + auth: { + type: AuthType.SigV4, + credentials: customAuthContent, + }, + }, + references: [], + }); + const authMethod: AuthenticationMethod = { + name, + authType: AuthType.SigV4, + credentialProvider: jest.fn(), + }; + authenticationMethodRegistery.getAuthenticationMethod.mockImplementation(() => authMethod); + + authRegistryCredentialProviderMock.mockReturnValue({ + credential: sigV4AuthContent, + type: AuthType.SigV4, + }); + + await configureClient( + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + clientPoolSetup, + config, + logger + ); + expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); + expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(savedObjectsMock.get).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 984d99565569..4ebee55ab2d6 100644 --- a/src/plugins/data_source/server/client/configure_client.ts +++ b/src/plugins/data_source/server/client/configure_client.ts @@ -7,7 +7,7 @@ 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 { Logger, OpenSearchDashboardsRequest } from '../../../../../src/core/server'; import { AuthType, DataSourceAttributes, @@ -27,6 +27,8 @@ import { getDataSource, generateCacheKey, } from './configure_client_utils'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authRegistryCredentialProvider } from '../util/credential_provider'; export const configureClient = async ( { @@ -35,6 +37,8 @@ export const configureClient = async ( cryptography, testClientDataSourceAttr, customApiSchemaRegistryPromise, + request, + authRegistry, }: DataSourceClientParams, openSearchClientPoolSetup: OpenSearchClientPoolSetup, config: DataSourcePluginConfigType, @@ -80,6 +84,8 @@ export const configureClient = async ( cryptography, rootClient, dataSourceId, + request, + authRegistry, requireDecryption ); } catch (error: any) { @@ -101,6 +107,8 @@ export const configureClient = async ( * @param config data source config * @param addClientToPool function to add client to client pool * @param dataSourceId id of data source saved Object + * @param request OpenSearch Dashboards incoming request to read client parameters from header. + * @param authRegistry registry to retrieve the credentials provider for the authentication method in order to return the client * @param requireDecryption false when creating test client before data source exists * @returns Promise of query client */ @@ -112,15 +120,31 @@ const getQueryClient = async ( cryptography?: CryptographyServiceSetup, rootClient?: Client, dataSourceId?: string, + request?: OpenSearchDashboardsRequest, + authRegistry?: IAuthenticationMethodRegistery, requireDecryption: boolean = true ): Promise => { - const { + let credential; + let { auth: { type }, - endpoint, + name, } = dataSourceAttr; + const { endpoint } = dataSourceAttr; + name = name ?? type; const clientOptions = parseClientOptions(config, endpoint, registeredSchema); const cacheKey = generateCacheKey(dataSourceAttr, dataSourceId); + const authenticationMethod = authRegistry?.getAuthenticationMethod(name); + if (authenticationMethod !== undefined) { + const credentialProvider = await authRegistryCredentialProvider(authenticationMethod, { + dataSourceAttr, + request, + cryptography, + }); + credential = credentialProvider.credential; + type = credentialProvider.type; + } + switch (type) { case AuthType.NoAuth: if (!rootClient) rootClient = new Client(clientOptions); @@ -129,9 +153,11 @@ const getQueryClient = async ( return rootClient.child(); case AuthType.UsernamePasswordType: - const credential = requireDecryption - ? await getCredential(dataSourceAttr, cryptography!) - : (dataSourceAttr.auth.credentials as UsernamePasswordTypedContent); + credential = + (credential as UsernamePasswordTypedContent) ?? + (requireDecryption + ? await getCredential(dataSourceAttr, cryptography!) + : (dataSourceAttr.auth.credentials as UsernamePasswordTypedContent)); if (!rootClient) rootClient = new Client(clientOptions); addClientToPool(cacheKey, type, rootClient); @@ -139,11 +165,13 @@ const getQueryClient = async ( return getBasicAuthClient(rootClient, credential); case AuthType.SigV4: - const awsCredential = requireDecryption - ? await getAWSCredential(dataSourceAttr, cryptography!) - : (dataSourceAttr.auth.credentials as SigV4Content); + credential = + (credential as SigV4Content) ?? + (requireDecryption + ? await getAWSCredential(dataSourceAttr, cryptography!) + : (dataSourceAttr.auth.credentials as SigV4Content)); - const awsClient = rootClient ? rootClient : getAWSClient(awsCredential, clientOptions); + const awsClient = rootClient ? rootClient : getAWSClient(credential, clientOptions); addClientToPool(cacheKey, type, awsClient); return awsClient; diff --git a/src/plugins/data_source/server/index.ts b/src/plugins/data_source/server/index.ts index b88b1beb863e..96e48eabab0e 100644 --- a/src/plugins/data_source/server/index.ts +++ b/src/plugins/data_source/server/index.ts @@ -11,6 +11,7 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { enabled: true, hideLocalCluster: true, + authTypes: true, }, schema: configSchema, }; diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.test.mocks.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.test.mocks.ts index e6c1b3363896..2f91e757fd28 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.test.mocks.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.test.mocks.ts @@ -16,3 +16,8 @@ export const parseClientOptionsMock = jest.fn(); jest.doMock('./client_config', () => ({ parseClientOptions: parseClientOptionsMock, })); + +export const authRegistryCredentialProviderMock = jest.fn(); +jest.doMock('../util/credential_provider', () => ({ + authRegistryCredentialProvider: authRegistryCredentialProviderMock, +})); 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 f5cae1307f5a..ebe356c58561 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 @@ -10,12 +10,18 @@ import { AuthType, DataSourceAttributes, SigV4Content } from '../../common/data_ import { DataSourcePluginConfigType } from '../../config'; import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; import { CryptographyServiceSetup } from '../cryptography_service'; -import { DataSourceClientParams, LegacyClientCallAPIParams } from '../types'; +import { DataSourceClientParams, LegacyClientCallAPIParams, AuthenticationMethod } from '../types'; import { OpenSearchClientPoolSetup } from '../client'; import { ConfigOptions } from 'elasticsearch'; -import { ClientMock, parseClientOptionsMock } from './configure_legacy_client.test.mocks'; +import { + ClientMock, + parseClientOptionsMock, + authRegistryCredentialProviderMock, +} from './configure_legacy_client.test.mocks'; import { configureLegacyClient } from './configure_legacy_client'; import { CustomApiSchemaRegistry } from '../schema_registry'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; @@ -29,6 +35,7 @@ describe('configureLegacyClient', () => { let configOptions: ConfigOptions; let dataSourceAttr: DataSourceAttributes; let sigV4AuthContent: SigV4Content; + let authenticationMethodRegistery: jest.Mocked; let mockOpenSearchClientInstance: { close: jest.Mock; @@ -48,6 +55,7 @@ describe('configureLegacyClient', () => { logger = loggingSystemMock.createLogger(); savedObjectsMock = savedObjectsClientMock.create(); cryptographyMock = cryptographyServiceSetupMock.create(); + authenticationMethodRegistery = authenticationMethodRegisteryMock.create(); config = { enabled: true, clientPool: { @@ -254,4 +262,47 @@ describe('configureLegacyClient', () => { expect(mockOpenSearchClientInstance.ping).toHaveBeenCalledTimes(1); expect(mockOpenSearchClientInstance.ping).toHaveBeenLastCalledWith(mockParams); }); + + test('configureLegacyClient should retunrn client from authentication registery if method present in registry', async () => { + const name = 'typeA'; + const customAuthContent = { + region: 'us-east-1', + roleARN: 'test-role', + }; + savedObjectsMock.get.mockReset().mockResolvedValueOnce({ + id: DATA_SOURCE_ID, + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes: { + ...dataSourceAttr, + auth: { + type: AuthType.SigV4, + credentials: customAuthContent, + }, + }, + references: [], + }); + const authMethod: AuthenticationMethod = { + name, + authType: AuthType.SigV4, + credentialProvider: jest.fn(), + }; + authenticationMethodRegistery.getAuthenticationMethod.mockImplementation(() => authMethod); + + authRegistryCredentialProviderMock.mockReturnValue({ + credential: sigV4AuthContent, + type: AuthType.SigV4, + }); + + await configureLegacyClient( + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + callApiParams, + clientPoolSetup, + config, + logger + ); + expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); + expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(savedObjectsMock.get).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 58905b33d85c..8ed1b42cfd2e 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.ts @@ -14,6 +14,7 @@ import { LegacyCallAPIOptions, LegacyOpenSearchErrorHelpers, Logger, + OpenSearchDashboardsRequest, } from '../../../../../src/core/server'; import { AuthType, @@ -34,6 +35,8 @@ import { getDataSource, generateCacheKey, } from '../client/configure_client_utils'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authRegistryCredentialProvider } from '../util/credential_provider'; export const configureLegacyClient = async ( { @@ -41,6 +44,8 @@ export const configureLegacyClient = async ( savedObjects, cryptography, customApiSchemaRegistryPromise, + request, + authRegistry, }: DataSourceClientParams, callApiParams: LegacyClientCallAPIParams, openSearchClientPoolSetup: OpenSearchClientPoolSetup, @@ -65,7 +70,9 @@ export const configureLegacyClient = async ( config, registeredSchema, rootClient, - dataSourceId + dataSourceId, + request, + authRegistry ); } catch (error: any) { logger.debug( @@ -96,15 +103,31 @@ const getQueryClient = async ( config: DataSourcePluginConfigType, registeredSchema: any[], rootClient?: LegacyClient, - dataSourceId?: string + dataSourceId?: string, + request?: OpenSearchDashboardsRequest, + authRegistry?: IAuthenticationMethodRegistery ) => { - const { + let credential; + let { auth: { type }, - endpoint: nodeUrl, + name, } = dataSourceAttr; + const { endpoint: nodeUrl } = dataSourceAttr; + name = name ?? type; const clientOptions = parseClientOptions(config, nodeUrl, registeredSchema); const cacheKey = generateCacheKey(dataSourceAttr, dataSourceId); + const authenticationMethod = authRegistry?.getAuthenticationMethod(name); + if (authenticationMethod !== undefined) { + const credentialProvider = await authRegistryCredentialProvider(authenticationMethod, { + dataSourceAttr, + request, + cryptography, + }); + credential = credentialProvider.credential; + type = credentialProvider.type; + } + switch (type) { case AuthType.NoAuth: if (!rootClient) rootClient = new LegacyClient(clientOptions); @@ -117,7 +140,9 @@ const getQueryClient = async ( ); case AuthType.UsernamePasswordType: - const credential = await getCredential(dataSourceAttr, cryptography); + credential = + (credential as UsernamePasswordTypedContent) ?? + (await getCredential(dataSourceAttr, cryptography)); if (!rootClient) rootClient = new LegacyClient(clientOptions); addClientToPool(cacheKey, type, rootClient); @@ -125,9 +150,10 @@ const getQueryClient = async ( return getBasicAuthClient(rootClient, { endpoint, clientParams, options }, credential); case AuthType.SigV4: - const awsCredential = await getAWSCredential(dataSourceAttr, cryptography); + credential = + (credential as SigV4Content) ?? (await getAWSCredential(dataSourceAttr, cryptography)); - const awsClient = rootClient ? rootClient : getAWSClient(awsCredential, clientOptions); + const awsClient = rootClient ? rootClient : getAWSClient(credential, clientOptions); addClientToPool(cacheKey, type, awsClient); return await (callAPI.bind(null, awsClient) as LegacyAPICaller)( diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index 6bccfbfad662..56b5f5caf2e8 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -129,7 +129,8 @@ export class DataSourcePlugin implements Plugin { @@ -144,6 +145,7 @@ export class DataSourcePlugin implements Plugin createDataSourceError(e), registerCredentialProvider, registerCustomApiSchema: (schema: any) => this.customApiSchemaRegistry.register(schema), + dataSourceEnabled: () => config.enabled, }; } @@ -168,7 +170,8 @@ export class DataSourcePlugin implements Plugin, customApiSchemaRegistryPromise: Promise ): IContextProvider, 'dataSource'> => { - return (context, req) => { + return async (context, req) => { + const authRegistry = await authRegistryPromise; return { opensearch: { getClient: (dataSourceId: string) => { @@ -181,6 +184,8 @@ export class DataSourcePlugin implements Plugin>; + +const URL = '/internal/data-source-management/validate'; + +describe(`Test connection ${URL}`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let cryptographyMock: jest.Mocked; + const customApiSchemaRegistry = new CustomApiSchemaRegistry(); + let customApiSchemaRegistryPromise: Promise; + let dataSourceClient: ReturnType; + let dataSourceServiceSetupMock: DataSourceServiceSetup; + let authRegistryPromiseMock: Promise; + const dataSourceAttr = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testUser', + password: 'testPassword', + }, + }, + }; + + const dataSourceAttrMissingCredentialForNoAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.NoAuth, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }; + + const dataSourceAttrPartialCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'service', + }, + }, + }; + + const dataSourceAttrPartialCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testName', + }, + }, + }; + + const dataSourceAttrForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'es', + secretKey: 'testSecret', + region: 'testRegion', + }, + }, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + customApiSchemaRegistryPromise = Promise.resolve(customApiSchemaRegistry); + authRegistryPromiseMock = Promise.resolve(authenticationMethodRegisteryMock.create()); + dataSourceClient = opensearchClientMock.createInternalClient(); + + dataSourceServiceSetupMock = { + getDataSourceClient: jest.fn(() => Promise.resolve(dataSourceClient)), + getDataSourceLegacyClient: jest.fn(), + }; + + const router = httpSetup.createRouter(''); + dataSourceClient.info.mockImplementationOnce(() => + opensearchClientMock.createSuccessTransportRequestPromise({ cluster_name: 'testCluster' }) + ); + registerTestConnectionRoute( + router, + dataSourceServiceSetupMock, + cryptographyMock, + authRegistryPromiseMock, + customApiSchemaRegistryPromise + ); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('shows successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr, + }) + .expect(200); + expect(result.body).toEqual({ success: true }); + expect(dataSourceServiceSetupMock.getDataSourceClient).toHaveBeenCalledWith( + expect.objectContaining({ + savedObjects: handlerContext.savedObjects.client, + cryptography: cryptographyMock, + dataSourceId: 'testId', + testClientDataSourceAttr: dataSourceAttr, + customApiSchemaRegistryPromise, + }) + ); + }); + + it('no credential with no auth should succeed', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForNoAuth, + }) + .expect(200); + expect(result.body).toEqual({ success: true }); + }); + + it('no credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('no credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('full credential with sigV4 auth should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForSigV4Auth, + }) + .expect(200); + expect(result.body).toEqual({ success: true }); + }); +}); diff --git a/src/plugins/data_source/server/routes/test_connection.ts b/src/plugins/data_source/server/routes/test_connection.ts index 85eea97c933c..ac6bc10ff39a 100644 --- a/src/plugins/data_source/server/routes/test_connection.ts +++ b/src/plugins/data_source/server/routes/test_connection.ts @@ -10,13 +10,16 @@ import { DataSourceConnectionValidator } from './data_source_connection_validato import { DataSourceServiceSetup } from '../data_source_service'; import { CryptographyServiceSetup } from '../cryptography_service'; import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { CustomApiSchemaRegistry } from '../schema_registry/custom_api_schema_registry'; -export const registerTestConnectionRoute = ( +export const registerTestConnectionRoute = async ( router: IRouter, dataSourceServiceSetup: DataSourceServiceSetup, cryptography: CryptographyServiceSetup, - authRegistryPromise: Promise + authRegistryPromise: Promise, + customApiSchemaRegistryPromise: Promise ) => { + const authRegistry = await authRegistryPromise; router.post( { path: '/internal/data-source-management/validate', @@ -26,30 +29,31 @@ export const registerTestConnectionRoute = ( dataSourceAttr: schema.object({ endpoint: schema.string(), auth: schema.maybe( - schema.object({ - type: schema.oneOf([ - schema.literal(AuthType.UsernamePasswordType), - schema.literal(AuthType.NoAuth), - schema.literal(AuthType.SigV4), - ]), - credentials: schema.maybe( - schema.oneOf([ - schema.object({ - username: schema.string(), - password: schema.string(), - }), - schema.object({ - region: schema.string(), - accessKey: schema.string(), - secretKey: schema.string(), - service: schema.oneOf([ - schema.literal(SigV4ServiceName.OpenSearch), - schema.literal(SigV4ServiceName.OpenSearchServerless), - ]), - }), - ]) - ), - }) + schema.oneOf([ + schema.object({ + type: schema.literal(AuthType.NoAuth), + credentials: schema.object({}), + }), + schema.object({ + type: schema.literal(AuthType.UsernamePasswordType), + credentials: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }), + schema.object({ + type: schema.literal(AuthType.SigV4), + credentials: schema.object({ + region: schema.string(), + accessKey: schema.string(), + secretKey: schema.string(), + service: schema.oneOf([ + schema.literal(SigV4ServiceName.OpenSearch), + schema.literal(SigV4ServiceName.OpenSearchServerless), + ]), + }), + }), + ]) ), }), }), @@ -65,6 +69,9 @@ export const registerTestConnectionRoute = ( cryptography, dataSourceId, testClientDataSourceAttr: dataSourceAttr as DataSourceAttributes, + request, + authRegistry, + customApiSchemaRegistryPromise, } ); diff --git a/src/plugins/data_source/server/types.ts b/src/plugins/data_source/server/types.ts index 9bd70b142d8b..12b975881e7e 100644 --- a/src/plugins/data_source/server/types.ts +++ b/src/plugins/data_source/server/types.ts @@ -37,6 +37,10 @@ export interface DataSourceClientParams { testClientDataSourceAttr?: DataSourceAttributes; // custom API schema registry promise, required for getting registered custom API schema customApiSchemaRegistryPromise: Promise; + // When client parameters are required to be retrieved from the request header, the caller should provide the request. + request?: OpenSearchDashboardsRequest; + // To retrieve the credentials provider for the authentication method from the registry in order to return the client. + authRegistry?: IAuthenticationMethodRegistery; } export interface DataSourceCredentialsProviderOptions { @@ -81,6 +85,7 @@ export interface DataSourcePluginSetup { createDataSourceError: (err: any) => DataSourceError; registerCredentialProvider: (method: AuthenticationMethod) => void; registerCustomApiSchema: (schema: any) => void; + dataSourceEnabled: () => boolean; } export interface DataSourcePluginStart { diff --git a/src/plugins/data_source/server/util/credential_provider.ts b/src/plugins/data_source/server/util/credential_provider.ts new file mode 100644 index 000000000000..d737c932fd95 --- /dev/null +++ b/src/plugins/data_source/server/util/credential_provider.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataSourceCredentialsProviderOptions, AuthenticationMethod } from '../types'; + +export const authRegistryCredentialProvider = async ( + authenticationMethod: AuthenticationMethod, + options: DataSourceCredentialsProviderOptions +) => ({ + credential: await authenticationMethod.credentialProvider(options), + type: authenticationMethod.authType, +}); diff --git a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts index 98cff913483f..00c3b0dbf0ee 100644 --- a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts +++ b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts @@ -8,8 +8,9 @@ import { EuiSuperSelectOption } from '@elastic/eui'; export interface AuthenticationMethod { name: string; - credentialForm: React.JSX.Element; credentialSourceOption: EuiSuperSelectOption; + credentialForm?: React.JSX.Element; + crendentialFormField?: { [key: string]: string }; } export type IAuthenticationMethodRegistery = Omit< diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/__snapshots__/create_data_source_form.test.tsx.snap b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/__snapshots__/create_data_source_form.test.tsx.snap new file mode 100644 index 000000000000..2bb1bd8053d7 --- /dev/null +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/__snapshots__/create_data_source_form.test.tsx.snap @@ -0,0 +1,1657 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 1`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: AWS SigV4, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 2`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: No authentication, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 3`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: Username & Password, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 4`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: No authentication, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 5`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: Username & Password, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 6`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: Username & Password, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Datasource Management: Create Datasource form with different authType configurations should render normally with all authMethod combinations 7`] = ` + + + } + isOpen={false} + panelPaddingSize="none" + > + + [Function] + + } + buttonRef={[Function]} + className="euiInputPopover euiSuperSelect" + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} + > + +
+
+ +
+ + + +
+
+ + + + Select an option: Username & Password, is selected + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx index c3c34cbdab1d..4f30ba9da5e4 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx @@ -11,7 +11,13 @@ import { OpenSearchDashboardsContextProvider } from '../../../../../../opensearc import { CreateDataSourceForm } from './create_data_source_form'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { AuthType } from '../../../../types'; +import { + AuthType, + noAuthCredentialAuthMethod, + sigV4AuthMethod, + usernamePasswordAuthMethod, +} from '../../../../types'; +import { AuthenticationMethodRegistery } from 'src/plugins/data_source_management/public/auth_registry'; const titleIdentifier = '[data-test-subj="createDataSourceFormTitleField"]'; const descriptionIdentifier = `[data-test-subj="createDataSourceFormDescriptionField"]`; @@ -24,6 +30,14 @@ const testConnectionButtonIdentifier = '[data-test-subj="createDataSourceTestCon describe('Datasource Management: Create Datasource form', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + noAuthCredentialAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + usernamePasswordAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + let component: ReactWrapper, React.Component<{}, {}, any>>; const mockSubmitHandler = jest.fn(); const mockTestConnectionHandler = jest.fn(); @@ -222,3 +236,130 @@ describe('Datasource Management: Create Datasource form', () => { expect(component.find(passwordIdentifier).first().props().isInvalid).toBe(false); }); }); + +describe('Datasource Management: Create Datasource form with different authType configurations', () => { + let component: ReactWrapper, React.Component<{}, {}, any>>; + const mockSubmitHandler = jest.fn(); + const mockTestConnectionHandler = jest.fn(); + const mockCancelHandler = jest.fn(); + + /* Scenario 1: Should render the page normally with all authMethod combinations */ + test('should render normally with all authMethod combinations', () => { + const authMethodCombinationsToBeTested = [ + [sigV4AuthMethod], + [noAuthCredentialAuthMethod], + [usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, sigV4AuthMethod], + [usernamePasswordAuthMethod, sigV4AuthMethod], + [noAuthCredentialAuthMethod, usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, usernamePasswordAuthMethod, sigV4AuthMethod], + ]; + + authMethodCombinationsToBeTested.forEach((authMethodCombination) => { + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + + authMethodCombination.forEach((authMethod) => { + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + }); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + const authOptionSelector = component.find(authTypeIdentifier).first(); + expect(authOptionSelector).toMatchSnapshot(); + }); + }); + + /* Scenario 2: options selector should be disabled when only one authMethod supported */ + test('options selector should be disabled when less than or equal to one authMethod supported', () => { + const authMethodCombinationsToBeTested = [ + [], + [sigV4AuthMethod], + [noAuthCredentialAuthMethod], + [usernamePasswordAuthMethod], + ]; + + authMethodCombinationsToBeTested.forEach((authMethodCombination) => { + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + + authMethodCombination.forEach((authMethod) => { + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + }); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + const authOptionSelector = component.find(authTypeIdentifier).last(); + expect(authOptionSelector.prop('disabled')).toBe(true); + }); + }); + + /* Scenario 3: options selector should not be disabled when more than one authMethod supported */ + test('options selector should not be disabled when more than one authMethod supported', () => { + const authMethodCombinationsToBeTested = [ + [sigV4AuthMethod, usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, sigV4AuthMethod], + [noAuthCredentialAuthMethod, usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod], + ]; + + authMethodCombinationsToBeTested.forEach((authMethodCombination) => { + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + + authMethodCombination.forEach((authMethod) => { + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + }); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + const authOptionSelector = component.find(authTypeIdentifier).last(); + expect(authOptionSelector.prop('disabled')).toBe(false); + }); + }); +}); 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 86052cff8995..e178ea1bcffa 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 @@ -18,13 +18,13 @@ import { EuiSuperSelect, EuiSpacer, EuiText, + EuiSuperSelectOption, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { AuthType, - credentialSourceOptions, DataSourceAttributes, DataSourceManagementContextValue, UsernamePasswordTypedContent, @@ -38,7 +38,7 @@ import { isTitleValid, performDataSourceFormValidation, } from '../../../validation'; -import { isValidUrl } from '../../../utils'; +import { getDefaultAuthMethod, isValidUrl } from '../../../utils'; export interface CreateDataSourceProps { existingDatasourceNamesList: string[]; @@ -55,7 +55,7 @@ export interface CreateDataSourceState { endpoint: string; auth: { type: AuthType; - credentials: UsernamePasswordTypedContent | SigV4Content; + credentials: UsernamePasswordTypedContent | SigV4Content | undefined; }; } @@ -66,19 +66,32 @@ export class CreateDataSourceForm extends React.Component< static contextType = contextType; public readonly context!: DataSourceManagementContextValue; + authOptions: Array> = []; + isNoAuthOptionEnabled: boolean; + constructor(props: CreateDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); + const authenticationMethodRegistery = context.services.authenticationMethodRegistery; + const registeredAuthMethods = authenticationMethodRegistery.getAllAuthenticationMethods(); + const initialSelectedAuthMethod = getDefaultAuthMethod(authenticationMethodRegistery); + + this.isNoAuthOptionEnabled = + authenticationMethodRegistery.getAuthenticationMethod(AuthType.NoAuth) !== undefined; + + this.authOptions = registeredAuthMethods.map((authMethod) => { + return authMethod.credentialSourceOption; + }); + this.state = { formErrorsByField: { ...defaultValidation }, title: '', description: '', endpoint: '', auth: { - type: AuthType.UsernamePasswordType, + type: initialSelectedAuthMethod?.name, credentials: { - username: '', - password: '', + ...initialSelectedAuthMethod?.crendentialFormField, }, }, }; @@ -297,6 +310,9 @@ export class CreateDataSourceForm extends React.Component< service: this.state.auth.credentials.service || SigV4ServiceName.OpenSearch, } as SigV4Content; } + if (this.state.auth.type === AuthType.NoAuth) { + credentials = {}; + } return { title: this.state.title, @@ -583,14 +599,22 @@ export class CreateDataSourceForm extends React.Component< - + {this.isNoAuthOptionEnabled && ( - + )} + {this.isNoAuthOptionEnabled && ( + + + + )} @@ -598,9 +622,10 @@ export class CreateDataSourceForm extends React.Component< this.onChangeAuthType(value)} + disabled={this.authOptions.length <= 1} name="Credential" data-test-subj="createDataSourceFormAuthTypeSelect" /> diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx index c57b5b414ed3..4326d6e6832d 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx @@ -15,7 +15,12 @@ import { import { OpenSearchDashboardsContextProvider } from '../../../../../../opensearch_dashboards_react/public'; import { EditDataSourceForm } from './edit_data_source_form'; import { act } from 'react-dom/test-utils'; -import { AuthType } from '../../../../types'; +import { + AuthType, + noAuthCredentialAuthMethod, + sigV4AuthMethod, + usernamePasswordAuthMethod, +} from '../../../../types'; const titleFieldIdentifier = 'dataSourceTitle'; const titleFormRowIdentifier = '[data-test-subj="editDataSourceTitleFormRow"]'; @@ -29,6 +34,14 @@ const passwordFieldIdentifier = '[data-test-subj="updateDataSourceFormPasswordFi const updatePasswordBtnIdentifier = '[data-test-subj="editDatasourceUpdatePasswordBtn"]'; describe('Datasource Management: Edit Datasource Form', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + noAuthCredentialAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + usernamePasswordAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + let component: ReactWrapper, React.Component<{}, {}, any>>; const mockFn = jest.fn(); 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 9af843a64071..c3d7daa7db48 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 @@ -20,6 +20,7 @@ import { EuiSuperSelect, EuiSpacer, EuiText, + EuiSuperSelectOption, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -27,7 +28,6 @@ import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/co import { Header } from '../header'; import { AuthType, - credentialSourceOptions, DataSourceAttributes, DataSourceManagementContextValue, sigV4ServiceOptions, @@ -71,10 +71,17 @@ export class EditDataSourceForm extends React.Component> = []; constructor(props: EditDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); + this.authOptions = context.services.authenticationMethodRegistery + .getAllAuthenticationMethods() + .map((authMethod) => { + return authMethod.credentialSourceOption; + }); + this.state = { formErrorsByField: { ...defaultValidation }, title: '', @@ -772,9 +779,10 @@ export class EditDataSourceForm extends React.Component this.onChangeAuthType(value)} + disabled={this.authOptions.length <= 1} name="Credential" data-test-subj="editDataSourceSelectAuthType" /> diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx index 5f6e823e0f86..c1516a507d4a 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx @@ -18,12 +18,25 @@ import { wrapWithIntl } from 'test_utils/enzyme_helpers'; import { RouteComponentProps } from 'react-router-dom'; import { OpenSearchDashboardsContextProvider } from '../../../../opensearch_dashboards_react/public'; import { EditDataSource } from './edit_data_source'; +import { + noAuthCredentialAuthMethod, + sigV4AuthMethod, + usernamePasswordAuthMethod, +} from '../../types'; const formIdentifier = 'EditDataSourceForm'; const notFoundIdentifier = '[data-test-subj="dataSourceNotFound"]'; describe('Datasource Management: Edit Datasource Wizard', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + noAuthCredentialAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + usernamePasswordAuthMethod + ); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + let component: ReactWrapper, React.Component<{}, {}, any>>; const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; diff --git a/src/plugins/data_source_management/public/components/utils.test.ts b/src/plugins/data_source_management/public/components/utils.test.ts index a94d5b2260e6..d19136d62f50 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -9,6 +9,7 @@ import { deleteMultipleDataSources, getDataSourceById, getDataSources, + getDefaultAuthMethod, isValidUrl, testConnection, updateDataSourceById, @@ -23,8 +24,14 @@ import { mockErrorResponseForSavedObjectsCalls, mockResponseForSavedObjectsCalls, } from '../mocks'; -import { AuthType } from '../types'; +import { + AuthType, + noAuthCredentialAuthMethod, + sigV4AuthMethod, + usernamePasswordAuthMethod, +} from '../types'; import { HttpStart } from 'opensearch-dashboards/public'; +import { AuthenticationMethodRegistery } from '../auth_registry'; const { savedObjects } = coreMock.createStart(); @@ -219,4 +226,50 @@ describe('DataSourceManagement: Utils.ts', () => { /* True cases: port number scenario*/ expect(isValidUrl('http://192.168.1.1:1234/')).toBeTruthy(); }); + + describe('Check default auth method', () => { + test('default auth method is Username & Password when Username & Password is enabled', () => { + const authMethodCombinationsToBeTested = [ + [usernamePasswordAuthMethod], + [sigV4AuthMethod, usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, usernamePasswordAuthMethod], + [noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod], + ]; + + authMethodCombinationsToBeTested.forEach((authOptions) => { + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + + authOptions.forEach((authMethod) => { + authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + }); + + expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe( + AuthType.UsernamePasswordType + ); + }); + }); + + test('default auth method is first one in AuthList when Username & Password is not enabled', () => { + const authMethodCombinationsToBeTested = [ + [sigV4AuthMethod], + [noAuthCredentialAuthMethod], + [sigV4AuthMethod, noAuthCredentialAuthMethod], + ]; + + authMethodCombinationsToBeTested.forEach((authOptions) => { + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + + authOptions.forEach((authMethod) => { + authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + }); + + expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe(authOptions[0].name); + }); + }); + + test('default auth type is NoAuth when no auth options registered in authenticationMethodRegistery, this should not happen in real customer scenario for MD', () => { + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe(AuthType.NoAuth); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 5f2cfb2337ad..726fcc527f30 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -4,7 +4,13 @@ */ import { HttpStart, SavedObjectsClientContract } from 'src/core/public'; -import { DataSourceAttributes, DataSourceTableItem } from '../types'; +import { + DataSourceAttributes, + DataSourceTableItem, + defaultAuthType, + noAuthCredentialAuthMethod, +} from '../types'; +import { AuthenticationMethodRegistery } from '../auth_registry'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -108,3 +114,19 @@ export const isValidUrl = (endpoint: string) => { return false; } }; + +export const getDefaultAuthMethod = ( + authenticationMethodRegistery: AuthenticationMethodRegistery +) => { + const registeredAuthMethods = authenticationMethodRegistery.getAllAuthenticationMethods(); + + const defaultAuthMethod = + registeredAuthMethods.length > 0 + ? authenticationMethodRegistery.getAuthenticationMethod(registeredAuthMethods[0].name) + : noAuthCredentialAuthMethod; + + const initialSelectedAuthMethod = + authenticationMethodRegistery.getAuthenticationMethod(defaultAuthType) ?? defaultAuthMethod; + + return initialSelectedAuthMethod; +}; diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index f61113042458..212c9ea5bd35 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -21,10 +21,12 @@ import { DataSourceManagementContext, DataSourceManagementStartDependencies } fr import { EditDataSourceWithRouter } from '../components/edit_data_source'; import { PageWrapper } from '../components/page_wrapper'; import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; +import { AuthenticationMethodRegistery } from '../auth_registry'; export async function mountDataSourcesManagementSection( getStartServices: StartServicesAccessor, - params: AppMountParameters + params: AppMountParameters, + authMethodsRegistry: AuthenticationMethodRegistery ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, @@ -49,6 +51,7 @@ export async function mountDataSourcesManagementSection( http, docLinks, setBreadcrumbs: setBreadcrumbsScoped, + authenticationMethodRegistery: authMethodsRegistry, }; ReactDOM.render( diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index 7b170c4a7c79..71e0310cb870 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -15,7 +15,7 @@ import { } from './plugin'; import { managementPluginMock } from '../../management/public/mocks'; import { mockManagementPlugin as indexPatternManagementPluginMock } from '../../index_pattern_management/public/mocks'; -import { AuthenticationMethod } from './auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistery } from './auth_registry'; /* Mock Types */ @@ -30,6 +30,8 @@ export const docLinks = { }, }; +export const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + const createDataSourceManagementContext = () => { const { chrome, @@ -51,6 +53,7 @@ const createDataSourceManagementContext = () => { http, docLinks, setBreadcrumbs: () => {}, + authenticationMethodRegistery, }; }; @@ -220,6 +223,13 @@ export const testDataSourceManagementPlugin = ( const setup = plugin.setup(coreSetup, { management: managementPluginMock.createSetupContract(), indexPatternManagement: indexPatternManagementPluginMock.createSetupContract(), + dataSource: { + dataSourceEnabled: true, + hideLocalCluster: true, + noAuthenticationTypeEnabled: true, + usernamePasswordAuthEnabled: true, + awsSigV4AuthEnabled: true, + }, }); const doStart = () => { const start = plugin.start(coreStart); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 2bf9d112ee7e..f52c39a33afd 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { AppMountParameters, CoreSetup, @@ -23,10 +24,12 @@ import { IAuthenticationMethodRegistery, AuthenticationMethodRegistery, } from './auth_registry'; +import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod } from './types'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; indexPatternManagement: IndexPatternManagementSetup; + dataSource: DataSourcePluginSetup; } export interface DataSourceManagementPluginSetup { @@ -51,7 +54,7 @@ export class DataSourceManagementPlugin public setup( core: CoreSetup, - { indexPatternManagement }: DataSourceManagementSetupDependencies + { management, indexPatternManagement, dataSource }: DataSourceManagementSetupDependencies ) { const savedObjectPromise = core .getStartServices() @@ -70,7 +73,8 @@ export class DataSourceManagementPlugin return mountDataSourcesManagementSection( core.getStartServices as StartServicesAccessor, - params + params, + this.authMethodsRegistry ); }, }); @@ -84,6 +88,16 @@ export class DataSourceManagementPlugin this.authMethodsRegistry.registerAuthenticationMethod(authMethod); }; + if (dataSource.noAuthenticationTypeEnabled) { + registerAuthenticationMethod(noAuthCredentialAuthMethod); + } + if (dataSource.usernamePasswordAuthEnabled) { + registerAuthenticationMethod(usernamePasswordAuthMethod); + } + if (dataSource.awsSigV4AuthEnabled) { + registerAuthenticationMethod(sigV4AuthMethod); + } + return { registerAuthenticationMethod }; } diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index 65e1b604d02c..8af5d170703b 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -19,6 +19,7 @@ import { SavedObjectAttributes } from 'src/core/types'; import { i18n } from '@osd/i18n'; import { SigV4ServiceName } from '../../data_source/common/data_sources'; import { OpenSearchDashboardsReactContextValue } from '../../opensearch_dashboards_react/public'; +import { AuthenticationMethodRegistery } from './auth_registry'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DataSourceManagementPluginStart {} @@ -33,6 +34,7 @@ export interface DataSourceManagementContext { http: HttpSetup; docLinks: DocLinksStart; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + authenticationMethodRegistery: AuthenticationMethodRegistery; } export interface DataSourceTableItem { @@ -59,26 +61,47 @@ export enum AuthType { SigV4 = 'sigv4', } -export const credentialSourceOptions = [ - { - value: AuthType.NoAuth, - inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.NoAuthentication', { - defaultMessage: 'No authentication', - }), - }, - { - value: AuthType.UsernamePasswordType, - inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.UsernamePassword', { - defaultMessage: 'Username & Password', - }), - }, - { - value: AuthType.SigV4, - inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.AwsSigV4', { - defaultMessage: 'AWS SigV4', - }), - }, -]; +export const defaultAuthType = AuthType.UsernamePasswordType; + +export const noAuthCredentialOption = { + value: AuthType.NoAuth, + inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.NoAuthentication', { + defaultMessage: 'No authentication', + }), +}; + +export const noAuthCredentialField = {}; + +export const noAuthCredentialAuthMethod = { + name: AuthType.NoAuth, + credentialSourceOption: noAuthCredentialOption, + crendentialFormField: noAuthCredentialField, +}; + +export const usernamePasswordCredentialOption = { + value: AuthType.UsernamePasswordType, + inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.UsernamePassword', { + defaultMessage: 'Username & Password', + }), +}; + +export const usernamePasswordCredentialField = { + username: '', + password: '', +}; + +export const usernamePasswordAuthMethod = { + name: AuthType.UsernamePasswordType, + credentialSourceOption: usernamePasswordCredentialOption, + crendentialFormField: usernamePasswordCredentialField, +}; + +export const sigV4CredentialOption = { + value: AuthType.SigV4, + inputDisplay: i18n.translate('dataSourceManagement.credentialSourceOptions.AwsSigV4', { + defaultMessage: 'AWS SigV4', + }), +}; export const sigV4ServiceOptions = [ { @@ -95,6 +118,25 @@ export const sigV4ServiceOptions = [ }, ]; +export const sigV4CredentialField = { + region: '', + accessKey: '', + secretKey: '', + service: '', +}; + +export const sigV4AuthMethod = { + name: AuthType.SigV4, + credentialSourceOption: sigV4CredentialOption, + crendentialFormField: sigV4CredentialField, +}; + +export const credentialSourceOptions = [ + noAuthCredentialOption, + usernamePasswordCredentialOption, + sigV4CredentialOption, +]; + export interface DataSourceAttributes extends SavedObjectAttributes { title: string; description?: string; diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss index c960e87a9477..e87c3e62ae1f 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss @@ -85,8 +85,8 @@ .osdDocTableCell__source { .truncate-by-height { - transform: translateY(-1.5px); - margin-bottom: -1.5px; + margin-top: -1.5px; + margin-bottom: -3.5px; } dd, diff --git a/src/plugins/discover/public/embeddable/search_embeddable.tsx b/src/plugins/discover/public/embeddable/search_embeddable.tsx index 79080cf8657f..e2c1b1271397 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/search_embeddable.tsx @@ -47,7 +47,6 @@ import { } from '../../../data/public'; import { Container, Embeddable } from '../../../embeddable/public'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; -import { getDefaultSort } from '../application/view_components/utils/get_default_sort'; import { getSortForSearchSource } from '../application/view_components/utils/get_sort_for_search_source'; import { getRequestInspectorStats, @@ -216,12 +215,6 @@ export class SearchEmbeddable return; } - const sort = getDefaultSort( - indexPattern, - this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') - ); - this.savedSearch.sort = sort; - const searchProps: SearchProps = { columns: this.savedSearch.columns, sort: [], diff --git a/src/plugins/maps_legacy/server/ui_settings.ts b/src/plugins/maps_legacy/server/ui_settings.ts index 4b2eb9714f7e..3209723da939 100644 --- a/src/plugins/maps_legacy/server/ui_settings.ts +++ b/src/plugins/maps_legacy/server/ui_settings.ts @@ -49,7 +49,7 @@ export function getUiSettings(): Record> { 'maps_legacy.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', values: { cellDimensionsLink: - `` + i18n.translate( 'maps_legacy.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts index 147f0ab159d3..062aee299758 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -38,6 +38,9 @@ describe('saved_objects_wrapper_for_check_workspace_conflict integration test', enabled: true, }, }, + migrations: { + skip: false, + }, }, }, }); @@ -104,6 +107,24 @@ describe('saved_objects_wrapper_for_check_workspace_conflict integration test', }); }); + it('create-with-override with unexisting object id', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}/foo?overwrite=true`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdFooWorkspace.id], + }) + .expect(200); + + expect(createResult.body.id).toEqual('foo'); + expect(createResult.body.workspaces).toEqual([createdFooWorkspace.id]); + + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + it('create-with-override', async () => { const createResult = await osdTestServer.request .post(root, `/api/saved_objects/${dashboard.type}`) diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts index cac06d789822..961accac262f 100644 --- a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -33,6 +33,9 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { }; wrapperInstance.setSerializer(savedObjectsSerializer); describe('createWithWorkspaceConflictCheck', () => { + beforeEach(() => { + mockedClient.create.mockClear(); + }); it(`Should reserve the workspace params when overwrite with empty workspaces`, async () => { mockedClient.get.mockResolvedValueOnce( getSavedObject({ @@ -84,6 +87,40 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { ) ).rejects.toThrowError('Saved object [dashboard/dashboard:foo] conflict'); }); + + it(`Should use options.workspaces when get throws error`, async () => { + mockedClient.get.mockRejectedValueOnce( + getSavedObject({ + id: 'dashboard:foo', + workspaces: ['foo'], + error: { + statusCode: 404, + error: 'Not found', + message: 'Not found', + }, + }) + ); + + await wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: ['bar'], + } + ); + + expect(mockedClient.create).toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + workspaces: ['bar'], + }) + ); + }); }); describe('bulkCreateWithWorkspaceConflictCheck', () => { @@ -136,6 +173,10 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { id: 'bar', workspaces: ['foo', 'bar'], }), + getSavedObject({ + id: 'qux', + workspaces: ['foo'], + }), ], }); mockedClient.bulkGet.mockResolvedValueOnce({ @@ -199,6 +240,7 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { id: 'qux', references: [], type: 'dashboard', + workspaces: ['foo'], }, ], { @@ -242,6 +284,15 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { "bar", ], }, + Object { + "attributes": Object {}, + "id": "qux", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, ], } `); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts index f2d0eb2c732c..a190fcc88613 100644 --- a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts @@ -12,7 +12,6 @@ import { SavedObjectsClientWrapperFactory, SavedObjectsCreateOptions, SavedObjectsErrorHelpers, - SavedObjectsUtils, SavedObjectsSerializer, SavedObjectsCheckConflictsObject, SavedObjectsCheckConflictsResponse, @@ -53,8 +52,14 @@ export class WorkspaceConflictSavedObjectsClientWrapper { let currentItem; try { currentItem = await wrapperOptions.client.get(type, id); - } catch (e) { - // If item can not be found, supress the error and create the object + } catch (e: unknown) { + const error = e as Boom.Boom; + if (error?.output?.statusCode === 404) { + // If item can not be found, supress the error and create the object + } else { + // Throw other error + throw e; + } } if (currentItem) { if ( @@ -105,6 +110,15 @@ export class WorkspaceConflictSavedObjectsClientWrapper { const bulkGetResult = await wrapperOptions.client.bulkGet(bulkGetDocs); bulkGetResult.saved_objects.forEach((object) => { + const { id, type } = object; + + /** + * If the object can not be found, create object by using options.workspaces + */ + if (object.error && object.error.statusCode === 404) { + objectsMapWorkspaces[this.getRawId({ namespace, type, id })] = options.workspaces; + } + /** * Skip the items with error, wrapperOptions.client will handle the error */ @@ -118,7 +132,6 @@ export class WorkspaceConflictSavedObjectsClientWrapper { options.workspaces, object.workspaces ); - const { id, type } = object; if (filteredWorkspaces.length) { /** * options.workspaces is not a subset of object.workspaces, diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index cb3d4455937b..8975ae86c1ad 100644 Binary files a/test/functional/screenshots/baseline/area_chart.png and b/test/functional/screenshots/baseline/area_chart.png differ diff --git a/test/functional/screenshots/baseline/tsvb_dashboard.png b/test/functional/screenshots/baseline/tsvb_dashboard.png index 0f3379feb320..9b03e260d52c 100644 Binary files a/test/functional/screenshots/baseline/tsvb_dashboard.png and b/test/functional/screenshots/baseline/tsvb_dashboard.png differ diff --git a/yarn.lock b/yarn.lock index e1ac33225f8d..177a2fdc26a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5164,7 +5164,7 @@ axe-core@^4.0.2, axe-core@^4.3.5: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413" integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw== -axios@^1.6.0, axios@^1.6.1: +axios@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.1.tgz#76550d644bf0a2d469a01f9244db6753208397d7" integrity sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g== @@ -5173,6 +5173,15 @@ axios@^1.6.0, axios@^1.6.1: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.5: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -6053,13 +6062,13 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== -chromedriver@^119.0.1: - version "119.0.1" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-119.0.1.tgz#064f3650790ccea055e9bfd95c600f5ea60295e9" - integrity sha512-lpCFFLaXPpvElTaUOWKdP74pFb/sJhWtWqMjn7Ju1YriWn8dT5JBk84BGXMPvZQs70WfCYWecxdMmwfIu1Mupg== +chromedriver@^121.0.1: + version "121.0.2" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-121.0.2.tgz#208909a61e9d510913107ea6faf34bcdd72cdced" + integrity sha512-58MUSCEE3oB3G3Y/Jo3URJ2Oa1VLHcVBufyYt7vNfGrABSJm7ienQLF9IQ8LPDlPVgLUXt2OBfggK3p2/SlEBg== dependencies: "@testim/chrome-version" "^1.1.4" - axios "^1.6.0" + axios "^1.6.5" compare-versions "^6.1.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.1"