From eeb68ca162f64f4d3b11a82f290029851b5c4e82 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 1 Mar 2024 02:07:04 +0800 Subject: [PATCH 01/18] [Admin] Add @ruanyl as a maintainer (#5982) * feat: add ruanyl as a maintainer Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .github/CODEOWNERS | 2 +- CHANGELOG.md | 1 + MAINTAINERS.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5649019bd089..71d59e0bb3d9 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 246d200143af..2ab6111c8c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - 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)) ### 🪛 Refactoring diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 83709bd6209a..ff99c035b859 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -22,6 +22,7 @@ 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 | ## Emeritus From c2ac4c8ae102a76df4258e8e871375299b6ad008 Mon Sep 17 00:00:00 2001 From: Tao Liu <33105471+Flyingliuhub@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:13:34 -0800 Subject: [PATCH 02/18] [Admin] Add @BionIT as a maintainer (#5988) * add BionIT as a maintainer Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> * update pr link in the changelog.md Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> --------- Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- CHANGELOG.md | 1 + MAINTAINERS.md | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 71d59e0bb3d9..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 @ruanyl +* @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/CHANGELOG.md b/CHANGELOG.md index 2ab6111c8c8e..042f8a5ac53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - 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 ff99c035b859..ccb2f491554e 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -22,7 +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 | +| Yulong Ruan | [ruanyl](https://github.com/ruanyl) | Amazon | +| Lu Yu | [BionIT](https://github.com/BionIT) | Amazon | ## Emeritus From 0c394bdaea9d864d821f0e7a01f09d3e66ef0048 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Fri, 1 Mar 2024 15:58:20 -0800 Subject: [PATCH 03/18] [MD]Fix schema for test connection to separate validation based on auth type (#5997) * fix schema for test connection Signed-off-by: Lu Yu * add changelog Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu --- CHANGELOG.md | 1 + .../server/routes/test_connection.test.ts | 124 ++++++++++++++++++ .../server/routes/test_connection.ts | 50 +++---- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 042f8a5ac53a..b7e9cff5c6e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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 diff --git a/src/plugins/data_source/server/routes/test_connection.test.ts b/src/plugins/data_source/server/routes/test_connection.test.ts index 9ad414e28660..fb6146949084 100644 --- a/src/plugins/data_source/server/routes/test_connection.test.ts +++ b/src/plugins/data_source/server/routes/test_connection.test.ts @@ -42,6 +42,64 @@ describe(`Test connection ${URL}`, () => { }, }; + 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); @@ -91,4 +149,70 @@ describe(`Test connection ${URL}`, () => { }) ); }); + + 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 b4c3d091aa35..ac6bc10ff39a 100644 --- a/src/plugins/data_source/server/routes/test_connection.ts +++ b/src/plugins/data_source/server/routes/test_connection.ts @@ -10,6 +10,7 @@ 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 = async ( router: IRouter, @@ -28,30 +29,31 @@ export const registerTestConnectionRoute = async ( 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), + ]), + }), + }), + ]) ), }), }), From 2c8d9d39028bf5fff38e4b0354765039aa2e7284 Mon Sep 17 00:00:00 2001 From: Tianle Huang <60111637+tianleh@users.noreply.github.com> Date: Fri, 1 Mar 2024 21:00:31 -0800 Subject: [PATCH 04/18] Add support for dynamic application configurations (#5855) * Add application configuration service Signed-off-by: Tianle Huang * update API path name Signed-off-by: Tianle Huang * implement two APIs/interfaces Signed-off-by: Tianle Huang * expose get function for other plugins to use Signed-off-by: Tianle Huang * update interfaces Signed-off-by: Tianle Huang * implement the APIs and interfaces Signed-off-by: Tianle Huang * add license and jsdoc Signed-off-by: Tianle Huang * update docs Signed-off-by: Tianle Huang * add more docs Signed-off-by: Tianle Huang * update variable name Signed-off-by: Tianle Huang * remove unnecessary dependency Signed-off-by: Tianle Huang * format readme Signed-off-by: Tianle Huang * use osd version Signed-off-by: Tianle Huang * remove debugging info Signed-off-by: Tianle Huang * update logging Signed-off-by: Tianle Huang * remove lint js Signed-off-by: Tianle Huang * remove logs Signed-off-by: Tianle Huang * update name style Signed-off-by: Tianle Huang * update Signed-off-by: Tianle Huang * update function visibility and error function Signed-off-by: Tianle Huang * fix unit test failures Signed-off-by: Tianle Huang * add unit test Signed-off-by: Tianle Huang * remove lint file Signed-off-by: Tianle Huang * add more tests Signed-off-by: Tianle Huang * add unit tests for routes Signed-off-by: Tianle Huang * add remaining unit tests Signed-off-by: Tianle Huang * add enabled to this plugin Signed-off-by: Tianle Huang * update readme to mention experimental Signed-off-by: Tianle Huang * update change log Signed-off-by: Tianle Huang * dummy commit to trigger workflow rerun Signed-off-by: Tianle Huang * remove experimental Signed-off-by: Tianle Huang * add key to yml file Signed-off-by: Tianle Huang * remove i18n Signed-off-by: Tianle Huang * remove lint rc Signed-off-by: Tianle Huang * update comment style Signed-off-by: Tianle Huang * add input validation Signed-off-by: Tianle Huang * update unit tests Signed-off-by: Tianle Huang * prevent multiple registration Signed-off-by: Tianle Huang * add return types Signed-off-by: Tianle Huang * update readme wording Signed-off-by: Tianle Huang * add unit test to the plugin class about double register Signed-off-by: Tianle Huang * move related ymls Signed-off-by: Tianle Huang * move validation to a function Signed-off-by: Tianle Huang * use trimmed versions Signed-off-by: Tianle Huang * reword changelog entry Signed-off-by: Tianle Huang * readability Signed-off-by: Tianle Huang * add back yml change Signed-off-by: Tianle Huang --------- Signed-off-by: Tianle Huang --- CHANGELOG.md | 1 + config/opensearch_dashboards.yml | 7 + src/core/server/mocks.ts | 1 + .../server/opensearch_dashboards_config.ts | 1 + .../server/plugins/plugin_context.test.ts | 1 + src/core/server/plugins/types.ts | 7 +- src/legacy/server/config/schema.js | 1 + src/plugins/application_config/README.md | 112 ++++++ .../application_config/common/index.ts | 7 + src/plugins/application_config/config.ts | 12 + .../opensearch_dashboards.json | 9 + .../application_config/server/index.ts | 23 ++ .../server/opensearch_config_client.test.ts | 359 ++++++++++++++++++ .../server/opensearch_config_client.ts | 123 ++++++ .../application_config/server/plugin.test.ts | 76 ++++ .../application_config/server/plugin.ts | 88 +++++ .../server/routes/index.test.ts | 353 +++++++++++++++++ .../application_config/server/routes/index.ts | 162 ++++++++ .../server/string_utils.test.ts | 44 +++ .../application_config/server/string_utils.ts | 26 ++ .../application_config/server/types.ts | 52 +++ 21 files changed, 1464 insertions(+), 1 deletion(-) create mode 100755 src/plugins/application_config/README.md create mode 100644 src/plugins/application_config/common/index.ts create mode 100644 src/plugins/application_config/config.ts create mode 100644 src/plugins/application_config/opensearch_dashboards.json create mode 100644 src/plugins/application_config/server/index.ts create mode 100644 src/plugins/application_config/server/opensearch_config_client.test.ts create mode 100644 src/plugins/application_config/server/opensearch_config_client.ts create mode 100644 src/plugins/application_config/server/plugin.test.ts create mode 100644 src/plugins/application_config/server/plugin.ts create mode 100644 src/plugins/application_config/server/routes/index.test.ts create mode 100644 src/plugins/application_config/server/routes/index.ts create mode 100644 src/plugins/application_config/server/string_utils.test.ts create mode 100644 src/plugins/application_config/server/string_utils.ts create mode 100644 src/plugins/application_config/server/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e9cff5c6e6..9e67c86790f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 4f86a8729a3e..f1858ac35dde 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" 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/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; +} From c6b4c34956d51c9fc60210ae9b0b3477d58eee5f Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 4 Mar 2024 10:14:05 +0800 Subject: [PATCH 05/18] [Workspace]Optional workspaces params in repository (#5949) * refact: move workspace specific logic to savedObjectWrapper Signed-off-by: SuZhou-Joe * fix: some error Signed-off-by: SuZhou-Joe * feat: fix test error Signed-off-by: SuZhou-Joe * feat: remove useless config in test Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe * feat: add more unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: revert test in repository.test.js Signed-off-by: SuZhou-Joe * feat: revert test in import_saved_objects.test.ts Signed-off-by: SuZhou-Joe * feat: revert test in repository.test.js Signed-off-by: SuZhou-Joe * feat: add type Signed-off-by: SuZhou-Joe * fix: bootstrap type error Signed-off-by: SuZhou-Joe * feat: optimize code and add comment Signed-off-by: SuZhou-Joe * fix: unit test error Signed-off-by: SuZhou-Joe * fix: integration test fail Signed-off-by: SuZhou-Joe * feat: add missing code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * Add permissions field to the mapping only if the permission control is enabled Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * feat: modify unit test Signed-off-by: SuZhou-Joe * fix: bulk create error Signed-off-by: SuZhou-Joe * fix: bulk create error Signed-off-by: SuZhou-Joe * feat: add new config in yml file Signed-off-by: SuZhou-Joe * feat: add new config in yml file Signed-off-by: SuZhou-Joe * feat: update yml file Signed-off-by: SuZhou-Joe * feat: fix unit test Signed-off-by: SuZhou-Joe * feat: do not skip migration when doing integration test Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe * feat: change flag variable Signed-off-by: SuZhou-Joe * feat: add test cases Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Signed-off-by: gaobinlong Co-authored-by: gaobinlong --- CHANGELOG.md | 1 + config/opensearch_dashboards.yml | 5 +- .../saved_objects/saved_objects_client.ts | 1 + .../export/get_sorted_objects_for_export.ts | 7 + .../saved_objects/import/check_conflicts.ts | 3 + .../import/create_saved_objects.ts | 3 + .../import/import_saved_objects.ts | 3 + .../import/resolve_import_errors.ts | 2 + src/core/server/saved_objects/import/types.ts | 4 + .../core/build_active_mappings.test.ts | 7 + .../migrations/core/build_active_mappings.ts | 14 +- .../migrations/core/index_migrator.test.ts | 55 +++ .../migrations/core/migration_context.ts | 16 +- .../opensearch_dashboards_migrator.test.ts | 15 +- .../opensearch_dashboards_migrator.ts | 11 +- .../saved_objects/routes/bulk_create.ts | 11 +- .../server/saved_objects/routes/create.ts | 12 +- .../server/saved_objects/routes/export.ts | 11 +- src/core/server/saved_objects/routes/find.ts | 5 + .../server/saved_objects/routes/import.ts | 9 + .../routes/resolve_import_errors.ts | 9 + .../saved_objects/saved_objects_service.ts | 8 + .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../service/lib/repository.test.js | 11 + .../saved_objects/service/lib/repository.ts | 14 +- .../lib/search_dsl/query_params.test.ts | 21 + .../service/lib/search_dsl/query_params.ts | 34 ++ .../service/lib/search_dsl/search_dsl.ts | 3 + .../service/saved_objects_client.ts | 8 + src/core/server/saved_objects/types.ts | 4 + src/core/types/saved_objects.ts | 2 + src/plugins/workspace/common/constants.ts | 2 + src/plugins/workspace/server/plugin.ts | 12 + ...apper_for_check_workspace_conflict.test.ts | 362 ++++++++++++++++ ...apper_for_check_workspace_conflict.test.ts | 402 ++++++++++++++++++ ...ts_wrapper_for_check_workspace_conflict.ts | 323 ++++++++++++++ 37 files changed, 1402 insertions(+), 15 deletions(-) create mode 100644 src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e67c86790f9..ec8685b699f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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 diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index f1858ac35dde..8de3b4f3f6ec 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -292,4 +292,7 @@ # 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 \ No newline at end of file +# vis_augmenter.pluginAugmentationEnabled: true + +# Set the value to true to enable workspace feature +# workspace.enabled: false \ No newline at end of file diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6e5482614e40..d6b6b6b6d89c 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -345,6 +345,7 @@ export class SavedObjectsClient { filter: 'filter', namespaces: 'namespaces', preference: 'preference', + workspaces: 'workspaces', }; const renamedQuery = renameKeys(renameMap, options); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 7bf6e9f6ccdc..ea944ff3307a 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -60,6 +60,8 @@ export interface SavedObjectsExportOptions { excludeExportDetails?: boolean; /** optional namespace to override the namespace used by the savedObjectsClient. */ namespace?: string; + /** optional workspaces to override the workspaces used by the savedObjectsClient. */ + workspaces?: string[]; } /** @@ -87,6 +89,7 @@ async function fetchObjectsToExport({ exportSizeLimit, savedObjectsClient, namespace, + workspaces, }: { objects?: SavedObjectsExportOptions['objects']; types?: string[]; @@ -94,6 +97,7 @@ async function fetchObjectsToExport({ exportSizeLimit: number; savedObjectsClient: SavedObjectsClientContract; namespace?: string; + workspaces?: string[]; }) { if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); @@ -121,6 +125,7 @@ async function fetchObjectsToExport({ search, perPage: exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + ...(workspaces ? { workspaces } : {}), }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -153,6 +158,7 @@ export async function exportSavedObjectsToStream({ includeReferencesDeep = false, excludeExportDetails = false, namespace, + workspaces, }: SavedObjectsExportOptions) { const rootObjects = await fetchObjectsToExport({ types, @@ -161,6 +167,7 @@ export async function exportSavedObjectsToStream({ savedObjectsClient, exportSizeLimit, namespace, + workspaces, }); let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts index 830f7f55d7c5..f36bcf3a8a92 100644 --- a/src/core/server/saved_objects/import/check_conflicts.ts +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -44,6 +44,7 @@ interface CheckConflictsParams { ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; + workspaces?: string[]; } const isUnresolvableConflict = (error: SavedObjectError) => @@ -56,6 +57,7 @@ export async function checkConflicts({ ignoreRegularConflicts, retries = [], createNewCopies, + workspaces, }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; @@ -77,6 +79,7 @@ export async function checkConflicts({ }); const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { namespace, + workspaces, }); const errorMap = checkConflictsResult.errors.reduce( (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), 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 6fd08520281e..6b0015851baf 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -41,6 +41,7 @@ interface CreateSavedObjectsParams { overwrite?: boolean; dataSourceId?: string; dataSourceTitle?: string; + workspaces?: string[]; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -60,6 +61,7 @@ export const createSavedObjects = async ({ overwrite, dataSourceId, dataSourceTitle, + workspaces, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -169,6 +171,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, + workspaces, }); expectedResults = bulkCreateResponse.saved_objects; } diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index a5744478fd7d..f2833c198e1b 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -57,6 +57,7 @@ export async function importSavedObjectsFromStream({ namespace, dataSourceId, dataSourceTitle, + workspaces, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -92,6 +93,7 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); @@ -142,6 +144,7 @@ export async function importSavedObjectsFromStream({ namespace, dataSourceId, dataSourceTitle, + ...(workspaces ? { workspaces } : {}), }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; 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 09207c893043..49b7c67b5ab6 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -61,6 +61,7 @@ export async function resolveSavedObjectsImportErrors({ createNewCopies, dataSourceId, dataSourceTitle, + workspaces, }: SavedObjectsResolveImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -163,6 +164,7 @@ export async function resolveSavedObjectsImportErrors({ overwrite, 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 73bc548b1f24..994b7e627189 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -189,6 +189,8 @@ export interface SavedObjectsImportOptions { createNewCopies: boolean; dataSourceId?: string; dataSourceTitle?: string; + /** if specified, will import in given workspaces */ + workspaces?: string[]; } /** @@ -212,6 +214,8 @@ export interface SavedObjectsResolveImportErrorsOptions { createNewCopies: boolean; 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 bf377a13a42e..439a962ad110 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, SavedObjectsMappingProperties, @@ -47,11 +48,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, 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 4bacfda3bd5a..7ed60b0aa526 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/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 5c2844d64813..056b1b795550 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -38,6 +38,9 @@ export const registerBulkCreateRoute = (router: IRouter) => { validate: { query: schema.object({ overwrite: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.arrayOf( schema.object({ @@ -62,7 +65,13 @@ export const registerBulkCreateRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { overwrite } = req.query; - const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite }); + const workspaces = req.query.workspaces + ? Array().concat(req.query.workspaces) + : undefined; + const result = await context.core.savedObjects.client.bulkCreate(req.body, { + overwrite, + workspaces, + }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index c8c330ba7774..4d22bd244a03 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -56,15 +56,23 @@ export const registerCreateRoute = (router: IRouter) => { ) ), initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + workspaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references, initialNamespaces } = req.body; + const { attributes, migrationVersion, references, initialNamespaces, workspaces } = req.body; - const options = { id, overwrite, migrationVersion, references, initialNamespaces }; + const options = { + id, + overwrite, + migrationVersion, + references, + initialNamespaces, + workspaces, + }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 2c808b731b4e..9325b632e40f 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -57,12 +57,20 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) search: schema.maybe(schema.string()), includeReferencesDeep: schema.boolean({ defaultValue: false }), excludeExportDetails: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe(schema.arrayOf(schema.string())), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const savedObjectsClient = context.core.savedObjects.client; - const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const { + type, + objects, + search, + excludeExportDetails, + includeReferencesDeep, + workspaces, + } = req.body; const types = typeof type === 'string' ? [type] : type; // need to access the registry for type validation, can't use the schema for this @@ -98,6 +106,7 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) exportSizeLimit: maxImportExportSize, includeReferencesDeep, excludeExportDetails, + workspaces, }); const docsToExport: string[] = await createPromiseFromStreams([ diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index dbc9bf9e3a0d..36fa7c2cd9f5 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -59,6 +59,9 @@ export const registerFindRoute = (router: IRouter) => { namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -67,6 +70,7 @@ export const registerFindRoute = (router: IRouter) => { const namespaces = typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + const workspaces = query.workspaces ? Array().concat(query.workspaces) : undefined; const result = await context.core.savedObjects.client.find({ perPage: query.per_page, @@ -81,6 +85,7 @@ export const registerFindRoute = (router: IRouter) => { fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, namespaces, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 259551298748..1fc739ea168c 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -61,6 +61,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -108,6 +111,11 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, @@ -117,6 +125,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) createNewCopies, 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 8e2113af6378..6bc667eba0df 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -59,6 +59,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO query: schema.object({ createNewCopies: schema.boolean({ defaultValue: false }), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.object({ file: schema.stream(), @@ -117,6 +120,11 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await resolveSavedObjectsImportErrors({ typeRegistry: context.core.savedObjects.typeRegistry, savedObjectsClient: context.core.savedObjects.client, @@ -124,6 +132,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, + workspaces, dataSourceId, dataSourceTitle, }); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 43296f340d85..3005c56dcf84 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({ @@ -557,6 +564,7 @@ export class SavedObjectsService this.logger, migrationsRetryDelay ), + opensearchDashboardsRawConfig: this.opensearchDashboardsRawConfig as Config, }); } } diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index ff840a1fac60..5c3e22ac646a 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId } = _source; + const { type, namespace, namespaces, originId, workspaces } = _source; const version = _seq_no != null || _primary_term != null @@ -91,6 +91,7 @@ export class SavedObjectsSerializer { ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), + ...(workspaces && { workspaces }), }; } @@ -112,6 +113,7 @@ export class SavedObjectsSerializer { updated_at, version, references, + workspaces, } = savedObj; const source = { [type]: attributes, @@ -122,6 +124,7 @@ export class SavedObjectsSerializer { ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), + ...(workspaces && { workspaces }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index d10ec75cdf41..473a63cf65f4 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -52,6 +52,7 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; + workspaces?: string[]; [typeMapping: string]: any; } @@ -69,6 +70,7 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; + workspaces?: string[]; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index fb5d366dd454..e50332ae514a 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -444,6 +444,7 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; const namespace = 'foo-namespace'; + const workspace = 'foo-workspace'; const getMockBulkCreateResponse = (objects, namespace) => { return { @@ -730,6 +731,16 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); }); + + it(`adds workspaces to request body for any types`, async () => { + await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); + const expected = expect.objectContaining({ workspaces: [workspace] }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index bccfd8ff2265..1a4feab322b3 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -243,6 +243,7 @@ export class SavedObjectsRepository { originId, initialNamespaces, version, + workspaces, } = options; const namespace = normalizeNamespace(options.namespace); @@ -289,6 +290,7 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), + ...(Array.isArray(workspaces) && { workspaces }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -438,6 +440,12 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version); } + let savedObjectWorkspaces = options.workspaces; + + if (expectedBulkGetResult.value.method !== 'create') { + savedObjectWorkspaces = object.workspaces; + } + const expectedResult = { opensearchRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, @@ -452,6 +460,7 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, + ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), }) as SavedObjectSanitizedDoc ), }; @@ -736,6 +745,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + workspaces, } = options; if (!type && !typeToNamespacesMap) { @@ -809,6 +819,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, hasReference, kueryNode, + workspaces, }), }, }; @@ -1754,7 +1765,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, workspaces } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1769,6 +1780,7 @@ function getSavedObjectFromSource( namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(workspaces && { workspaces }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 518e2ff56d0e..a47bc27fcd92 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -625,6 +625,27 @@ describe('#getQueryParams', () => { ]); }); }); + + describe('when using workspace search', () => { + it('using normal workspaces', () => { + const result: Result = getQueryParams({ + registry, + workspaces: ['foo'], + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must: [{ term: { workspaces: 'foo' } }], + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + }); }); describe('namespaces property', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 5bbb0a1fe24f..b78c5a032992 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -128,6 +128,27 @@ function getClauseForType( }; } +/** + * Gets the clause that will filter for the workspace. + */ +function getClauseForWorkspace(workspace: string) { + if (workspace === '*') { + return { + bool: { + must: { + match_all: {}, + }, + }, + }; + } + + return { + bool: { + must: [{ term: { workspaces: workspace } }], + }, + }; +} + interface HasReferenceQueryParams { type: string; id: string; @@ -144,6 +165,7 @@ interface QueryParams { defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; + workspaces?: string[]; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -200,6 +222,7 @@ export function getQueryParams({ defaultSearchOperator, hasReference, kueryNode, + workspaces, }: QueryParams) { const types = getTypes( registry, @@ -224,6 +247,17 @@ export function getQueryParams({ ], }; + if (workspaces) { + bool.filter.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } + if (search) { const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); const simpleQueryStringClause = getSimpleQueryStringClause({ diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 8b54141a4c3c..df6109eb9d0a 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -52,6 +52,7 @@ interface GetSearchDslOptions { id: string; }; kueryNode?: KueryNode; + workspaces?: string[]; } export function getSearchDsl( @@ -71,6 +72,7 @@ export function getSearchDsl( typeToNamespacesMap, hasReference, kueryNode, + workspaces, } = options; if (!type) { @@ -93,6 +95,7 @@ export function getSearchDsl( defaultSearchOperator, hasReference, kueryNode, + workspaces, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; 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 5f92dacacf36..d3edb0d98845 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -68,6 +68,10 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + /** + * workspaces the new created objects belong to + */ + workspaces?: string[]; } /** @@ -91,6 +95,10 @@ export interface SavedObjectsBulkCreateObject { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + /** + * workspaces the objects belong to, will only be used when overwrite is enabled. + */ + workspaces?: string[]; } /** diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 3e2553b8ce51..4ab6978a3dc1 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -110,6 +110,8 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional OpenSearch preference value to be used for the query **/ preference?: string; + /** If specified, will only retrieve objects that are in the workspaces */ + workspaces?: string[]; } /** @@ -119,6 +121,8 @@ export interface SavedObjectsFindOptions { export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; + /** Specify the workspaces for this operation */ + workspaces?: string[]; } /** diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 81e1ed029ddc..a683863d8df6 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -113,6 +113,8 @@ export interface SavedObject { * space. */ originId?: string; + /** Workspace(s) that this saved object exists in. */ + workspaces?: string[]; } export interface SavedObjectError { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index b6bd7b00f676..e60bb6aea0eb 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -4,3 +4,5 @@ */ export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; +export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = + 'workspace_conflict_control'; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 38e8a3c18f8c..e4ed75bad615 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -13,10 +13,13 @@ import { import { IWorkspaceClientImpl } from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; +import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; export class WorkspacePlugin implements Plugin<{}, {}> { private readonly logger: Logger; private client?: IWorkspaceClientImpl; + private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); @@ -29,6 +32,14 @@ export class WorkspacePlugin implements Plugin<{}, {}> { await this.client.setup(core); + this.workspaceConflictControl = new WorkspaceConflictSavedObjectsClientWrapper(); + + core.savedObjects.addClientWrapper( + -1, + WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceConflictControl.wrapperFactory + ); + registerRoutes({ http: core.http, logger: this.logger, @@ -43,6 +54,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { public start(core: CoreStart) { this.logger.debug('Starting Workspace service'); this.client?.setSavedObjects(core.savedObjects); + this.workspaceConflictControl?.setSerializer(core.savedObjects.createSerializer()); return { client: this.client as IWorkspaceClientImpl, 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 new file mode 100644 index 000000000000..75b19bb225b0 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -0,0 +1,362 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from 'src/core/types'; +import { isEqual } from 'lodash'; +import * as osdTestServer from '../../../../../core/test_helpers/osd_server'; + +const dashboard: Omit = { + type: 'dashboard', + attributes: {}, + references: [], +}; + +interface WorkspaceAttributes { + id: string; + name?: string; +} + +describe('saved_objects_wrapper_for_check_workspace_conflict integration test', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + let createdFooWorkspace: WorkspaceAttributes = { + id: '', + }; + let createdBarWorkspace: WorkspaceAttributes = { + id: '', + }; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + migrations: { + skip: false, + }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + const createWorkspace = (workspaceAttribute: Omit) => + osdTestServer.request.post(root, `/api/workspaces`).send({ + attributes: workspaceAttribute, + }); + + createdFooWorkspace = await createWorkspace({ + name: 'foo', + }).then((resp) => resp.body.result); + createdBarWorkspace = await createWorkspace({ + name: 'bar', + }).then((resp) => resp.body.result); + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + + const deleteItem = async (object: Pick) => { + expect( + [200, 404].includes( + (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) + .statusCode + ) + ).toEqual(true); + }; + + const getItem = async (object: Pick) => { + return await osdTestServer.request + .get(root, `/api/saved_objects/${object.type}/${object.id}`) + .expect(200); + }; + + const clearFooAndBar = async () => { + await deleteItem({ + type: dashboard.type, + id: 'foo', + }); + await deleteItem({ + type: dashboard.type, + id: 'bar', + }); + }; + + describe('workspace related CRUD', () => { + it('create', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdFooWorkspace.id], + }) + .expect(200); + + expect(createResult.body.workspaces).toEqual([createdFooWorkspace.id]); + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + 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}`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdFooWorkspace.id], + }) + .expect(200); + + await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdBarWorkspace.id], + }) + .expect(409); + + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('bulk create', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultFoo.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, [createdFooWorkspace.id]) + ) + ).toEqual(true); + expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultBar.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, [createdBarWorkspace.id]) + ) + ).toEqual(true); + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('bulk create with conflict', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + /** + * overwrite with workspaces + */ + const overwriteWithWorkspacesResult = await osdTestServer.request + .post( + root, + `/api/saved_objects/_bulk_create?overwrite=true&workspaces=${createdFooWorkspace.id}` + ) + .send([ + { + ...dashboard, + id: 'bar', + }, + { + ...dashboard, + id: 'foo', + attributes: { + title: 'foo', + }, + }, + ]) + .expect(200); + + expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo'); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual([ + createdFooWorkspace.id, + ]); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('checkConflicts when importing ndjson', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const getResultFoo = await getItem({ + type: dashboard.type, + id: 'foo', + }); + const getResultBar = await getItem({ + type: dashboard.type, + id: 'bar', + }); + + /** + * import with workspaces when conflicts + */ + const importWithWorkspacesResult = await osdTestServer.request + .post( + root, + `/api/saved_objects/_import?workspaces=${createdFooWorkspace.id}&overwrite=false` + ) + .attach( + 'file', + Buffer.from( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(false); + expect(importWithWorkspacesResult.body.errors.length).toEqual(1); + expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); + expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('find by workspaces', async () => { + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const findResult = await osdTestServer.request + .get( + root, + `/api/saved_objects/_find?workspaces=${createdBarWorkspace.id}&type=${dashboard.type}` + ) + .expect(200); + + expect(findResult.body.total).toEqual(1); + expect(findResult.body.saved_objects[0].workspaces).toEqual([createdBarWorkspace.id]); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + }); +}); 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 new file mode 100644 index 000000000000..961accac262f --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -0,0 +1,402 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from '../../../../core/public'; +import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; +import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects_wrapper_for_check_workspace_conflict'; +import { SavedObjectsSerializer } from '../../../../core/server'; + +describe('WorkspaceConflictSavedObjectsClientWrapper', () => { + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const wrapperInstance = new WorkspaceConflictSavedObjectsClientWrapper(); + const mockedClient = savedObjectsClientMock.create(); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: httpServerMock.createOpenSearchDashboardsRequest(), + }); + const savedObjectsSerializer = new SavedObjectsSerializer( + requestHandlerContext.savedObjects.typeRegistry + ); + const getSavedObject = (savedObject: Partial) => { + const payload: SavedObject = { + references: [], + id: '', + type: 'dashboard', + attributes: {}, + ...savedObject, + }; + + return payload; + }; + 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({ + id: 'dashboard:foo', + workspaces: ['foo'], + }) + ); + + await wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: [], + } + ); + + expect(mockedClient.create).toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + workspaces: ['foo'], + }) + ); + }); + + it(`Should return error when overwrite with conflict workspaces`, async () => { + mockedClient.get.mockResolvedValueOnce( + getSavedObject({ + id: 'dashboard:foo', + workspaces: ['foo'], + }) + ); + + await expect( + wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: ['bar'], + } + ) + ).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', () => { + beforeEach(() => { + mockedClient.bulkCreate.mockClear(); + }); + it(`Should create objects when no workspaces and id present`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + await wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + }), + ]); + + expect(mockedClient.bulkGet).not.toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [{ attributes: {}, id: 'foo', references: [], type: 'dashboard' }], + {} + ); + }); + + it(`Should create objects when not overwrite`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + await wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + ]); + + expect(mockedClient.bulkGet).not.toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [{ attributes: {}, id: 'foo', references: [], type: 'dashboard', workspaces: ['foo'] }], + {} + ); + }); + + it(`Should check conflict on workspace when overwrite`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + getSavedObject({ + id: 'qux', + workspaces: ['foo'], + }), + ], + }); + mockedClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + getSavedObject({ + id: 'baz', + workspaces: ['baz'], + }), + getSavedObject({ + id: 'qux', + error: { + statusCode: 404, + message: 'object not found', + error: 'object not found', + }, + }), + ], + }); + const result = await wrapperClient.bulkCreate( + [ + getSavedObject({ + id: 'foo', + }), + getSavedObject({ + id: 'bar', + }), + getSavedObject({ + id: 'baz', + }), + getSavedObject({ + id: 'qux', + }), + ], + { + overwrite: true, + workspaces: ['foo'], + } + ); + + expect(mockedClient.bulkGet).toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [ + { attributes: {}, id: 'foo', references: [], type: 'dashboard', workspaces: ['foo'] }, + { + attributes: {}, + id: 'bar', + references: [], + type: 'dashboard', + workspaces: ['foo', 'bar'], + }, + { + attributes: {}, + id: 'qux', + references: [], + type: 'dashboard', + workspaces: ['foo'], + }, + ], + { + overwrite: true, + workspaces: ['foo'], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object {}, + "error": Object { + "error": "Conflict", + "message": "Saved object [dashboard/baz] conflict", + "metadata": Object { + "isNotOverwritable": true, + }, + "statusCode": 409, + }, + "id": "baz", + "references": Array [], + "type": "dashboard", + }, + Object { + "attributes": Object {}, + "id": "foo", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + Object { + "attributes": Object {}, + "id": "bar", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + "bar", + ], + }, + Object { + "attributes": Object {}, + "id": "qux", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + ], + } + `); + }); + }); + + describe('checkConflictWithWorkspaceConflictCheck', () => { + beforeEach(() => { + mockedClient.bulkGet.mockClear(); + }); + + it(`Return early when no objects`, async () => { + const result = await wrapperClient.checkConflicts([]); + expect(result.errors).toEqual([]); + expect(mockedClient.bulkGet).not.toBeCalled(); + }); + + it(`Should filter out workspace conflict objects`, async () => { + mockedClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + mockedClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + getSavedObject({ + id: 'baz', + workspaces: ['baz'], + }), + getSavedObject({ + id: 'qux', + error: { + statusCode: 404, + message: 'object not found', + error: 'object not found', + }, + }), + ], + }); + const result = await wrapperClient.checkConflicts( + [ + getSavedObject({ + id: 'foo', + }), + getSavedObject({ + id: 'bar', + }), + getSavedObject({ + id: 'baz', + }), + getSavedObject({ + id: 'qux', + }), + ], + { + workspaces: ['foo'], + } + ); + + expect(mockedClient.bulkGet).toBeCalled(); + expect(mockedClient.checkConflicts).toBeCalledWith( + [ + { attributes: {}, id: 'foo', references: [], type: 'dashboard' }, + { + attributes: {}, + id: 'bar', + references: [], + type: 'dashboard', + }, + { + attributes: {}, + id: 'qux', + references: [], + type: 'dashboard', + }, + ], + { + workspaces: ['foo'], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "error": Object { + "error": "Conflict", + "message": "Saved object [dashboard/baz] conflict", + "metadata": Object { + "isNotOverwritable": true, + }, + "statusCode": 409, + }, + "id": "baz", + "type": "dashboard", + }, + ], + } + `); + }); + }); +}); 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 new file mode 100644 index 000000000000..a190fcc88613 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts @@ -0,0 +1,323 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Boom from '@hapi/boom'; +import { + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsErrorHelpers, + SavedObjectsSerializer, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, +} from '../../../../core/server'; + +const errorContent = (error: Boom.Boom) => error.output.payload; + +const filterWorkspacesAccordingToSourceWorkspaces = ( + targetWorkspaces?: string[], + baseWorkspaces?: string[] +): string[] => targetWorkspaces?.filter((item) => !baseWorkspaces?.includes(item)) || []; + +export class WorkspaceConflictSavedObjectsClientWrapper { + private _serializer?: SavedObjectsSerializer; + public setSerializer(serializer: SavedObjectsSerializer) { + this._serializer = serializer; + } + private getRawId(props: { namespace?: string; id: string; type: string }) { + return ( + this._serializer?.generateRawId(props.namespace, props.type, props.id) || + `${props.type}:${props.id}` + ); + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const createWithWorkspaceConflictCheck = async ( + type: string, + attributes: T, + options: SavedObjectsCreateOptions = {} + ) => { + const { workspaces, id, overwrite } = options; + let savedObjectWorkspaces = options?.workspaces; + + /** + * Check if overwrite with id + * If so, need to reserve the workspace params + */ + if (id && overwrite) { + let currentItem; + try { + currentItem = await wrapperOptions.client.get(type, id); + } 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 ( + filterWorkspacesAccordingToSourceWorkspaces(workspaces, currentItem.workspaces).length + ) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + savedObjectWorkspaces = currentItem.workspaces; + } + } + } + + return await wrapperOptions.client.create(type, attributes, { + ...options, + workspaces: savedObjectWorkspaces, + }); + }; + + const bulkCreateWithWorkspaceConflictCheck = async ( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ): Promise> => { + const { overwrite, namespace } = options; + /** + * When overwrite, filter out all the objects that have ids + */ + const bulkGetDocs = overwrite + ? objects + .filter((object) => !!object.id) + .map((object) => { + const { type, id } = object; + /** + * It requires a check when overwriting objects to target workspaces + */ + return { + type, + id: id as string, + fields: ['id', 'workspaces'], + }; + }) + : []; + const objectsConflictWithWorkspace: SavedObject[] = []; + const objectsMapWorkspaces: Record = {}; + if (bulkGetDocs.length) { + /** + * Get latest status of objects + */ + 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 + */ + if (!object.error && object.id) { + /** + * When it is about to overwrite a object into options.workspace. + * We need to check if the options.workspaces is the subset of object.workspaces, + * Or it will be treated as a conflict + */ + const filteredWorkspaces = filterWorkspacesAccordingToSourceWorkspaces( + options.workspaces, + object.workspaces + ); + if (filteredWorkspaces.length) { + /** + * options.workspaces is not a subset of object.workspaces, + * Add the item into conflict array. + */ + objectsConflictWithWorkspace.push({ + id, + type, + attributes: {}, + references: [], + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }); + } else { + /** + * options.workspaces is a subset of object's workspaces + * Add the workspaces status into a objectId -> workspaces pairs for later use. + */ + objectsMapWorkspaces[this.getRawId({ namespace, type, id })] = object.workspaces; + } + } + }); + } + + /** + * Get all the objects that do not conflict on workspaces + */ + const objectsNoWorkspaceConflictError = objects.filter( + (item) => + !objectsConflictWithWorkspace.find( + (errorItems) => + this.getRawId({ namespace, type: errorItems.type, id: errorItems.id }) === + this.getRawId({ namespace, type: item.type, id: item.id as string }) + ) + ); + + /** + * Add the workspaces params back based on objects' workspaces value in index. + */ + const objectsPayload = objectsNoWorkspaceConflictError.map((item) => { + if (item.id) { + const workspacesParamsInIndex = + objectsMapWorkspaces[ + this.getRawId({ + namespace, + id: item.id, + type: item.type, + }) + ]; + if (workspacesParamsInIndex) { + item.workspaces = workspacesParamsInIndex; + } + } + + return item; + }); + + /** + * Bypass those objects that are not conflict on workspaces check. + */ + const realBulkCreateResult = await wrapperOptions.client.bulkCreate(objectsPayload, options); + + /** + * Merge the workspaceConflict result and real client bulkCreate result. + */ + return { + ...realBulkCreateResult, + saved_objects: [ + ...objectsConflictWithWorkspace, + ...(realBulkCreateResult?.saved_objects || []), + ], + } as SavedObjectsBulkResponse; + }; + + const checkConflictWithWorkspaceConflictCheck = async ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) => { + const objectsConflictWithWorkspace: SavedObjectsCheckConflictsResponse['errors'] = []; + /** + * Fail early when no objects + */ + if (objects.length === 0) { + return { errors: [] }; + } + + /** + * Workspace conflict only happens when target workspaces params present. + */ + if (options.workspaces) { + const bulkGetDocs: any[] = objects.map((object) => { + const { type, id } = object; + + return { + type, + id, + fields: ['id', 'workspaces'], + }; + }); + + if (bulkGetDocs.length) { + const bulkGetResult = await wrapperOptions.client.bulkGet(bulkGetDocs); + + bulkGetResult.saved_objects.forEach((object) => { + const { id, type } = object; + /** + * Skip the error ones, real checkConflict in repository will handle that. + */ + if (!object.error) { + let workspaceConflict = false; + const filteredWorkspaces = filterWorkspacesAccordingToSourceWorkspaces( + options.workspaces, + object.workspaces + ); + if (filteredWorkspaces.length) { + workspaceConflict = true; + } + if (workspaceConflict) { + objectsConflictWithWorkspace.push({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }); + } + } + }); + } + } + + const objectsNoWorkspaceConflictError = objects.filter( + (item) => + !objectsConflictWithWorkspace.find( + (errorItems) => + this.getRawId({ + namespace: options.namespace, + type: errorItems.type, + id: errorItems.id, + }) === + this.getRawId({ + namespace: options.namespace, + type: item.type, + id: item.id as string, + }) + ) + ); + + /** + * Bypass those objects that are not conflict on workspaces + */ + const realBulkCreateResult = await wrapperOptions.client.checkConflicts( + objectsNoWorkspaceConflictError, + options + ); + + /** + * Merge results from two conflict check. + */ + const result: SavedObjectsCheckConflictsResponse = { + ...realBulkCreateResult, + errors: [...objectsConflictWithWorkspace, ...realBulkCreateResult.errors], + }; + + return result; + }; + + return { + ...wrapperOptions.client, + create: createWithWorkspaceConflictCheck, + bulkCreate: bulkCreateWithWorkspaceConflictCheck, + checkConflicts: checkConflictWithWorkspaceConflictCheck, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; + }; + + constructor() {} +} From d407f5522062f4f6ad76b73510becde42726b3f3 Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Sun, 3 Mar 2024 18:47:32 -0800 Subject: [PATCH 06/18] [BUG][Discover] Enable 'Back to Top' Feature in Discover for scrolling to top (#6008) * [BUG][Discover] Enable 'Back to Top' Feature in Discover for scrolling to top dscCanvas is the one with scrollable prop. Set window.scrollTo(0, 0) on table will not work. In this PR, we add a ref to EuiPanel directly. Issue Resolve: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6006 --------- Signed-off-by: Anan Zhuang Co-authored-by: Miki --- CHANGELOG.md | 3 ++- .../application/components/data_grid/data_grid_table.tsx | 4 ++++ .../default_discover_table/default_discover_table.tsx | 4 +++- .../view_components/canvas/discover_table.tsx | 5 +++-- .../public/application/view_components/canvas/index.tsx | 9 ++++++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8685b699f8..14fba16ff10a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) +- [BUG][MD]Fix schema for test connection to separate validation based on auth type ([#5997](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5997)) +- [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) ### 🚞 Infrastructure diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx index a0c1851e7716..d4b8de6ad211 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx @@ -46,6 +46,7 @@ export interface DataGridTableProps { isContextView?: boolean; isLoading?: boolean; showPagination?: boolean; + scrollToTop?: () => void; } export const DataGridTable = ({ @@ -67,6 +68,7 @@ export const DataGridTable = ({ isContextView = false, isLoading = false, showPagination, + scrollToTop, }: DataGridTableProps) => { const services = getServices(); const [inspectedHit, setInspectedHit] = useState(); @@ -179,6 +181,7 @@ export const DataGridTable = ({ isShortDots={isShortDots} hideTimeColumn={hideTimeColumn} defaultSortOrder={defaultSortOrder} + scrollToTop={scrollToTop} /> ), [ @@ -197,6 +200,7 @@ export const DataGridTable = ({ defaultSortOrder, hideTimeColumn, isShortDots, + scrollToTop, ] ); diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx index fe8092ed8c9c..d563f1c1d098 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx +++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx @@ -33,6 +33,7 @@ export interface DefaultDiscoverTableProps { hideTimeColumn: boolean; defaultSortOrder: SortDirection; showPagination?: boolean; + scrollToTop?: () => void; } export const LegacyDiscoverTable = ({ @@ -52,6 +53,7 @@ export const LegacyDiscoverTable = ({ hideTimeColumn, defaultSortOrder, showPagination, + scrollToTop, }: DefaultDiscoverTableProps) => { const displayedColumns = getLegacyDisplayedColumns( columns, @@ -173,7 +175,7 @@ export const LegacyDiscoverTable = ({ values={{ sampleSize }} /> - window.scrollTo(0, 0)}> + diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index 17f9f26e8b54..ccf82e4ccba0 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -12,7 +12,6 @@ import { addColumn, moveColumn, removeColumn, - reorderColumn, setColumns, setSort, useDispatch, @@ -27,9 +26,10 @@ import { popularizeField } from '../../helpers/popularize_field'; interface Props { rows?: OpenSearchSearchHit[]; + scrollToTop?: () => void; } -export const DiscoverTable = ({ rows }: Props) => { +export const DiscoverTable = ({ rows, scrollToTop }: Props) => { const { services } = useOpenSearchDashboards(); const { uiSettings, @@ -115,6 +115,7 @@ export const DiscoverTable = ({ rows }: Props) => { displayTimeColumn={displayTimeColumn} title={savedSearch?.id ? savedSearch.title : ''} description={savedSearch?.id ? savedSearch.description : ''} + scrollToTop={scrollToTop} /> ); }; diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index e3efe878aa83..1c2681995f98 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -24,6 +24,7 @@ import './discover_canvas.scss'; // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { + const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { services: { uiSettings }, @@ -89,9 +90,15 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro }, [dispatch, filteredColumns, indexPattern]); const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; + const scrollToTop = () => { + if (panelRef.current) { + panelRef.current.scrollTop = 0; + } + }; return ( - + )} From 4943f069b1046a6e55973ae03626657d098f95f5 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Sun, 3 Mar 2024 23:10:41 -0800 Subject: [PATCH 07/18] fixes buid and test workflow (#6010) Signed-off-by: Ashwin P Chandran --- .github/workflows/build_and_test_workflow.yml | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 36d75b5b5369..ec3c10c88779 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: ['main', '[0-9].x', '[0-9].[0=9]+'] # Run the functional test on push for only release branches + branches: ['main', '[0-9]+\.x', '[0-9]+\.[0-9]+'] # Run the functional test on push for only release branches paths-ignore: - '**/*.md' - 'docs/**' @@ -25,7 +25,7 @@ env: TEST_OPENSEARCH_TRANSPORT_PORT: 9403 TEST_OPENSEARCH_PORT: 9400 OSD_SNAPSHOT_SKIP_VERIFY_CHECKSUM: true - NODE_OPTIONS: "--max-old-space-size=6144 --dns-result-order=ipv4first" + NODE_OPTIONS: '--max-old-space-size=6144 --dns-result-order=ipv4first' jobs: build-lint-test: @@ -53,7 +53,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -160,7 +160,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -254,7 +254,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -364,7 +364,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -441,7 +441,19 @@ jobs: working-directory: ./artifacts strategy: matrix: - version: [osd-2.0.0, osd-2.1.0, osd-2.2.0, osd-2.3.0, osd-2.4.0, osd-2.5.0, osd-2.6.0, osd-2.7.0, osd-2.8.0, osd-2.9.0] + version: + [ + osd-2.0.0, + osd-2.1.0, + osd-2.2.0, + osd-2.3.0, + osd-2.4.0, + osd-2.5.0, + osd-2.6.0, + osd-2.7.0, + osd-2.8.0, + osd-2.9.0, + ] steps: - name: Checkout code uses: actions/checkout@v3 From 9901bea341904421eb802a004476cdd313811b1e Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 4 Mar 2024 16:09:19 +0800 Subject: [PATCH 08/18] [Workspace]Following pr for #5949 (#6012) * feat: temp save Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: add some comment Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: Ashwin P Chandran --- .../migrations/core/build_active_mappings.ts | 2 +- .../migrations/core/migration_context.ts | 2 +- .../opensearch_dashboards_migrator.ts | 3 +-- ..._wrapper_for_check_workspace_conflict.test.ts | 16 +++++----------- ...jects_wrapper_for_check_workspace_conflict.ts | 12 ++++++++++-- 5 files changed, 18 insertions(+), 17 deletions(-) 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 439a962ad110..01a7ba11b707 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,7 +34,7 @@ import crypto from 'crypto'; import { cloneDeep, mapValues } from 'lodash'; -import { Config } from 'packages/osd-config/target'; +import { Config } from '@osd/config'; import { IndexMapping, SavedObjectsMappingProperties, 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 8a1e9b648bce..91114701d95f 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -36,7 +36,7 @@ */ import { Logger } from 'src/core/server/logging'; -import { Config } from 'packages/osd-config/target'; +import { Config } from '@osd/config'; import { MigrationOpenSearchClient } from './migration_opensearch_client'; import { SavedObjectsSerializer } from '../../serialization'; import { 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 468aea3e905d..d6c119569a2e 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 @@ -35,8 +35,7 @@ import { OpenSearchDashboardsConfigType } from 'src/core/server/opensearch_dashboards_config'; import { BehaviorSubject } from 'rxjs'; - -import { Config } from 'packages/osd-config/target'; +import { Config } from '@osd/config'; import { Logger } from '../../../logging'; import { IndexMapping, SavedObjectsTypeMappingDefinitions } from '../../mappings'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; 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 961accac262f..9c29684e58e4 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 @@ -89,17 +89,11 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { }); 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', - }, - }) - ); + mockedClient.get.mockRejectedValueOnce({ + output: { + statusCode: 404, + }, + }); await wrapperClient.create( 'dashboard', 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 a190fcc88613..298d0448031a 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 @@ -35,6 +35,13 @@ export class WorkspaceConflictSavedObjectsClientWrapper { `${props.type}:${props.id}` ); } + + /** + * Workspace is a concept to manage saved objects and the `workspaces` field of each object indicates workspaces the object belongs to. + * When user tries to update an existing object's attribute, workspaces field should be preserved. Below are some cases that this conflict wrapper will take effect: + * 1. Overwrite a object belonging to workspace A with parameter workspace B, in this case we should deny the request as it conflicts with workspaces check. + * 2. Overwrite a object belonging to workspace [A, B] with parameters workspace B, we need to preserved the workspaces fields to [A, B]. + */ public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { const createWithWorkspaceConflictCheck = async ( type: string, @@ -90,10 +97,11 @@ export class WorkspaceConflictSavedObjectsClientWrapper { ? objects .filter((object) => !!object.id) .map((object) => { - const { type, id } = object; /** - * It requires a check when overwriting objects to target workspaces + * If the object waiting to import has id and type, + * Add it to the buldGetDocs to fetch the latest metadata. */ + const { type, id } = object; return { type, id: id as string, From 5ef10da3f1be11b2f920c4abfa3c1982c500541c Mon Sep 17 00:00:00 2001 From: Xinrui Bai-amazon <139305463+xinruiba@users.noreply.github.com> Date: Mon, 4 Mar 2024 17:50:57 -0800 Subject: [PATCH 09/18] [Multi Data Source] Render credential form registered from AuthMethod (#6002) * [TokenExchange] Render credential form registered from AuthMethod Signed-off-by: Xinrui Bai * [UT] Add unittest to test registered credential form get rendered in create datasource page Signed-off-by: Xinrui Bai * [UT] Update test case descriptions Signed-off-by: Xinrui Bai * [Token Exchange] improve code format in create datasource page Signed-off-by: Xinrui Bai * [UT] Add unit test for edit datasource page Signed-off-by: Xinrui Bai * Update changelog file Signed-off-by: Xinrui Bai * update yml config file to original status Signed-off-by: Xinrui Bai * Resolving comments Signed-off-by: Xinrui Bai * [UT] Add more unit test to cover existing auth type and plugin registered Auth type scenario Signed-off-by: Xinrui Bai * Resolving comments, update pmport path Signed-off-by: Xinrui Bai --------- Signed-off-by: Xinrui Bai --- CHANGELOG.md | 1 + .../authentication_methods_registry.ts | 5 +- .../create_data_source_form.test.tsx | 157 +++++++++++++++++- .../create_form/create_data_source_form.tsx | 39 +++-- .../edit_form/edit_data_source_form.test.tsx | 43 +++++ .../edit_form/edit_data_source_form.tsx | 31 +++- 6 files changed, 259 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fba16ff10a..f251407addf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) +- [Multiple Datasource] Refactoring create and edit form to use authentication registry ([#6002](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6002)) ### 🐛 Bug Fixes 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 00c3b0dbf0ee..a4152cb0627a 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 @@ -9,7 +9,10 @@ import { EuiSuperSelectOption } from '@elastic/eui'; export interface AuthenticationMethod { name: string; credentialSourceOption: EuiSuperSelectOption; - credentialForm?: React.JSX.Element; + credentialForm?: ( + state: { [key: string]: any }, + setState: React.Dispatch> + ) => React.JSX.Element; crendentialFormField?: { [key: string]: string }; } 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 4f30ba9da5e4..1040a17584a0 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 @@ -17,7 +17,7 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../../../../types'; -import { AuthenticationMethodRegistery } from 'src/plugins/data_source_management/public/auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../../../auth_registry'; const titleIdentifier = '[data-test-subj="createDataSourceFormTitleField"]'; const descriptionIdentifier = `[data-test-subj="createDataSourceFormDescriptionField"]`; @@ -363,3 +363,158 @@ describe('Datasource Management: Create Datasource form with different authType }); }); }); + +describe('Datasource Management: Create Datasource form with registered Auth Type', () => { + let component: ReactWrapper, React.Component<{}, {}, any>>; + const mockSubmitHandler = jest.fn(); + const mockTestConnectionHandler = jest.fn(); + const mockCancelHandler = jest.fn(); + + test('should call registered crendential form at the first round when registered method is at the first place and username & password disabled', () => { + const mockCredentialForm = jest.fn(); + const authTypeToBeTested = 'Some Auth Type'; + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + credentialForm: mockCredentialForm, + } as AuthenticationMethod; + + const authMethodCombinationsToBeTested = [ + [authMethodToBeTested], + [authMethodToBeTested, sigV4AuthMethod], + [authMethodToBeTested, noAuthCredentialAuthMethod], + [authMethodToBeTested, noAuthCredentialAuthMethod, 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, + }, + } + ); + + expect(mockCredentialForm).toHaveBeenCalled(); + }); + }); + + test('should not call registered crendential form at the first round when registered method is at the first place and username & password enabled', () => { + const mockCredentialForm = jest.fn(); + const authTypeToBeTested = 'Some Auth Type'; + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + credentialForm: mockCredentialForm, + } as AuthenticationMethod; + + const authMethodCombinationsToBeTested = [ + [authMethodToBeTested, usernamePasswordAuthMethod], + [authMethodToBeTested, usernamePasswordAuthMethod, sigV4AuthMethod], + [authMethodToBeTested, usernamePasswordAuthMethod, noAuthCredentialAuthMethod], + [ + authMethodToBeTested, + usernamePasswordAuthMethod, + noAuthCredentialAuthMethod, + 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, + }, + } + ); + + expect(mockCredentialForm).not.toHaveBeenCalled(); + }); + }); + + test('should not call registered crendential form at the first round when registered method is not at the first place', () => { + const mockCredentialForm = jest.fn(); + const authTypeToBeTested = 'Some Auth Type'; + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + credentialForm: mockCredentialForm, + } as AuthenticationMethod; + + const authMethodCombinationsToBeTested = [ + [sigV4AuthMethod, authMethodToBeTested], + [noAuthCredentialAuthMethod, authMethodToBeTested], + [noAuthCredentialAuthMethod, authMethodToBeTested, 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, + }, + } + ); + + expect(mockCredentialForm).not.toHaveBeenCalled(); + }); + }); +}); 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 e178ea1bcffa..25b082b8c6a1 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 @@ -22,6 +22,7 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; +import { AuthenticationMethodRegistery } from '../../../../auth_registry'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { AuthType, @@ -68,16 +69,17 @@ export class CreateDataSourceForm extends React.Component< authOptions: Array> = []; isNoAuthOptionEnabled: boolean; + authenticationMethodRegistery: AuthenticationMethodRegistery; constructor(props: CreateDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); - const authenticationMethodRegistery = context.services.authenticationMethodRegistery; - const registeredAuthMethods = authenticationMethodRegistery.getAllAuthenticationMethods(); - const initialSelectedAuthMethod = getDefaultAuthMethod(authenticationMethodRegistery); + this.authenticationMethodRegistery = context.services.authenticationMethodRegistery; + const registeredAuthMethods = this.authenticationMethodRegistery.getAllAuthenticationMethods(); + const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistery); this.isNoAuthOptionEnabled = - authenticationMethodRegistery.getAuthenticationMethod(AuthType.NoAuth) !== undefined; + this.authenticationMethodRegistery.getAuthenticationMethod(AuthType.NoAuth) !== undefined; this.authOptions = registeredAuthMethods.map((authMethod) => { return authMethod.credentialSourceOption; @@ -322,6 +324,23 @@ export class CreateDataSourceForm extends React.Component< }; }; + handleStateChange = (state: any) => { + this.setState(state); + }; + + getCredentialFormFromRegistry = (authType: string) => { + const registeredAuthMethod = this.authenticationMethodRegistery.getAuthenticationMethod( + authType + ); + const authCredentialForm = registeredAuthMethod?.credentialForm; + + if (authCredentialForm !== undefined) { + return authCredentialForm(this.state, this.handleStateChange); + } + + return null; + }; + /* Render methods */ /* Render header*/ @@ -362,6 +381,8 @@ export class CreateDataSourceForm extends React.Component< /* Render create new credentials*/ renderCreateNewCredentialsForm = (type: AuthType) => { switch (type) { + case AuthType.NoAuth: + return null; case AuthType.UsernamePasswordType: return ( <> @@ -498,7 +519,7 @@ export class CreateDataSourceForm extends React.Component< ); default: - break; + return this.getCredentialFormFromRegistry(type); } }; @@ -632,13 +653,7 @@ export class CreateDataSourceForm extends React.Component< {/* Create New credentials */} - {this.state.auth.type === AuthType.UsernamePasswordType - ? this.renderCreateNewCredentialsForm(this.state.auth.type) - : null} - - {this.state.auth.type === AuthType.SigV4 - ? this.renderCreateNewCredentialsForm(this.state.auth.type) - : null} + {this.renderCreateNewCredentialsForm(this.state.auth.type)} 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 4326d6e6832d..89d5c54cbc2a 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 @@ -21,6 +21,7 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../../../../types'; +import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../../../auth_registry'; const titleFieldIdentifier = 'dataSourceTitle'; const titleFormRowIdentifier = '[data-test-subj="editDataSourceTitleFormRow"]'; @@ -340,3 +341,45 @@ describe('Datasource Management: Edit Datasource Form', () => { }); }); }); + +describe('With Registered Authentication', () => { + let component: ReactWrapper, React.Component<{}, {}, any>>; + const mockCredentialForm = jest.fn(); + + test('should call registered crendential form', () => { + const authTypeToBeTested = 'Some Auth Type'; + const authMethodToBeTest = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + credentialForm: mockCredentialForm, + } as AuthenticationMethod; + + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTest); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + expect(mockCredentialForm).toHaveBeenCalled(); + }); +}); 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 c3d7daa7db48..ee46af355312 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 @@ -24,6 +24,7 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; +import { AuthenticationMethodRegistery } from '../../../../auth_registry'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { Header } from '../header'; import { @@ -43,6 +44,7 @@ import { } from '../../../validation'; import { UpdatePasswordModal } from '../update_password_modal'; import { UpdateAwsCredentialModal } from '../update_aws_credential_modal'; +import { getDefaultAuthMethod } from '../../../utils'; export interface EditDataSourceProps { existingDataSource: DataSourceAttributes; @@ -72,23 +74,27 @@ export class EditDataSourceForm extends React.Component> = []; + authenticationMethodRegistery: AuthenticationMethodRegistery; constructor(props: EditDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); - this.authOptions = context.services.authenticationMethodRegistery + this.authenticationMethodRegistery = context.services.authenticationMethodRegistery; + this.authOptions = this.authenticationMethodRegistery .getAllAuthenticationMethods() .map((authMethod) => { return authMethod.credentialSourceOption; }); + const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistery); + this.state = { formErrorsByField: { ...defaultValidation }, title: '', description: '', endpoint: '', auth: { - type: AuthType.NoAuth, + type: initialSelectedAuthMethod?.name, credentials: { username: '', password: '', @@ -518,6 +524,23 @@ export class EditDataSourceForm extends React.Component { + this.setState(state); + }; + + getCredentialFormFromRegistry = (authType: string) => { + const registeredAuthMethod = this.authenticationMethodRegistery.getAuthenticationMethod( + authType + ); + const authCredentialForm = registeredAuthMethod?.credentialForm; + + if (authCredentialForm !== undefined) { + return authCredentialForm(this.state, this.handleStateChange); + } + + return null; + }; + /* Render methods */ /* Render modal for new credential */ @@ -796,12 +819,14 @@ export class EditDataSourceForm extends React.Component { switch (type) { + case AuthType.NoAuth: + return null; case AuthType.UsernamePasswordType: return this.renderUsernamePasswordFields(); case AuthType.SigV4: return this.renderSigV4ContentFields(); default: - return null; + return this.getCredentialFormFromRegistry(type); } }; From d822b334bb794b8cecb5a5449db4344b27102134 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 4 Mar 2024 19:15:00 -0800 Subject: [PATCH 10/18] Add release notes for 1.3.15 (#5999) (#6019) (cherry picked from commit 1da1320caec9825130e5c70d0415f9251ba61dd9) Signed-off-by: abbyhu2000 Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] --- ...nsearch-dashboards.release-notes-1.3.15.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 release-notes/opensearch-dashboards.release-notes-1.3.15.md diff --git a/release-notes/opensearch-dashboards.release-notes-1.3.15.md b/release-notes/opensearch-dashboards.release-notes-1.3.15.md new file mode 100644 index 000000000000..c9fd349e3468 --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-1.3.15.md @@ -0,0 +1,20 @@ +# Version 1.3.14 Release Notes + +### 🛡 Security + +### 📈 Features/Enhancements + +### 🐛 Bug Fixes + +- [OSCI][fix] Discover: Fix the Subfields Display Issue ([#5429](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5429)) +- [BUG][data] Support for custom filters with heterogeneous data fields ([#5577](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5577)) + +### 🚞 Infrastructure + +### 📝 Documentation + +### 🛠 Maintenance + +- [Version] Increment version to 1.3.15 ([#5596](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5596)) +- 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)) From 54c36fec14034eef3221efc5553b651e92370f18 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 5 Mar 2024 17:11:47 +0800 Subject: [PATCH 11/18] [Workspace][Feature] Add ACL related functions (#5084) * [Workspace] Add ACL related functions for workspace (#146) * [Workspace] Add acl related functions for workspace Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong * Modify changelog Signed-off-by: gaobinlong * Add more unit test cases Signed-off-by: gaobinlong * Modify test case Signed-off-by: gaobinlong * Some minor change Signed-off-by: gaobinlong * Add more test cases Signed-off-by: gaobinlong * Optimize some code and the comments of the functions Signed-off-by: gaobinlong * Add more comments for some basic functions Signed-off-by: gaobinlong * Export more interfaces Signed-off-by: gaobinlong * consume permissions in repository Signed-off-by: SuZhou-Joe * feat: consume permissions in serializer Signed-off-by: SuZhou-Joe * Add unit tests for consuming permissions in repository Signed-off-by: gaobinlong * Remove double exclamation Signed-off-by: gaobinlong * Rename some variables Signed-off-by: gaobinlong * Remove duplicated semicolon Signed-off-by: gaobinlong * Add permissions field to the mapping only if the permission control is enabled Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Add feature flag config to the yml file Signed-off-by: gaobinlong * Make the comment of feature flag more clear Signed-off-by: gaobinlong * Make comment more clear Signed-off-by: gaobinlong * Remove management permission type Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong Signed-off-by: SuZhou-Joe Co-authored-by: Josh Romero Co-authored-by: SuZhou-Joe --- CHANGELOG.md | 1 + config/opensearch_dashboards.yml | 7 +- .../core/build_active_mappings.test.ts | 6 + .../migrations/core/build_active_mappings.ts | 24 ++ .../migrations/core/index_migrator.test.ts | 95 ++++- .../opensearch_dashboards_migrator.test.ts | 29 +- .../permission_control/acl.test.ts | 348 ++++++++++++++++++ .../saved_objects/permission_control/acl.ts | 337 +++++++++++++++++ .../saved_objects/permission_control/index.ts | 6 + .../saved_objects/saved_objects_config.ts | 3 + .../saved_objects_service.test.ts | 9 +- .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../service/lib/repository.test.js | 179 ++++++++- .../saved_objects/service/lib/repository.ts | 19 +- .../service/saved_objects_client.ts | 7 +- src/core/types/saved_objects.ts | 3 + 17 files changed, 1051 insertions(+), 29 deletions(-) create mode 100644 src/core/server/saved_objects/permission_control/acl.test.ts create mode 100644 src/core/server/saved_objects/permission_control/acl.ts create mode 100644 src/core/server/saved_objects/permission_control/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f251407addf1..12f9a43f16b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Replace OuiSelect component with OuiSuperSelect in data-source plugin ([#5315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5315)) - [Workspace] Add core workspace service module to enable the implementation of workspace features within OSD plugins ([#5092](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5092)) - [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) +- [Workspace] Add ACL related functions ([#5084](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5084/)) - [Decouple] Add new cross compatibility check core service which export functionality for plugins to verify if their OpenSearch plugin counterpart is installed on the cluster or has incompatible version to configure the plugin behavior([#4710](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4710)) - [Discover] Add long numerals support [#5592](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5592) - [Discover] Display inner properties in the left navigation bar [#5429](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5429) diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 8de3b4f3f6ec..b2710ad4cba6 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -294,5 +294,10 @@ # Set the value of this setting to true to enable plugin augmentation on Dashboard # vis_augmenter.pluginAugmentationEnabled: true +# Set the value to true to enable permission control for saved objects +# Permission control depends on OpenSearch Dashboards has authentication enabled, set it to false when the security plugin is not installed, +# if the security plugin is not installed and this config is true, permission control takes no effect. +# savedObjects.permission.enabled: true + # Set the value to true to enable workspace feature -# workspace.enabled: false \ No newline at end of file +# workspace.enabled: false 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 4acc161c4bab..5fb3bb3b4c8a 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 @@ -93,6 +93,12 @@ describe('buildActiveMappings', () => { expect(hashes.aaa).not.toEqual(hashes.ccc); }); + test('permissions field is added when permission control flag is enabled', () => { + const rawConfig = configMock.create(); + rawConfig.get.mockReturnValue(true); + expect(buildActiveMappings({}, rawConfig)).toHaveProperty('properties.permissions'); + }); + test('workspaces field is added when workspace feature flag is enabled', () => { const rawConfig = configMock.create(); rawConfig.get.mockReturnValue(true); 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 01a7ba11b707..55b73daabc3e 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 @@ -37,6 +37,7 @@ import { cloneDeep, mapValues } from 'lodash'; import { Config } from '@osd/config'; import { IndexMapping, + SavedObjectsFieldMapping, SavedObjectsMappingProperties, SavedObjectsTypeMappingDefinitions, } from './../../mappings'; @@ -55,6 +56,29 @@ export function buildActiveMappings( 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('savedObjects.permission.enabled')) { + const principals: SavedObjectsFieldMapping = { + properties: { + users: { + type: 'keyword', + }, + groups: { + type: 'keyword', + }, + }, + }; + mergedProperties = validateAndMerge(mapping.properties, { + permissions: { + properties: { + read: principals, + write: principals, + library_read: principals, + library_write: principals, + }, + }, + }); + } + if (opensearchDashboardsRawConfig?.get('workspace.enabled')) { mergedProperties = validateAndMerge(mapping.properties, { workspaces: { 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 7ed60b0aa526..8b1f5df9640a 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 @@ -60,12 +60,105 @@ describe('IndexMigrator', () => { }; }); + test('creates the index when permission control for saved objects is enabled', async () => { + const { client } = testOpts; + + testOpts.mappingProperties = { foo: { type: 'long' } as any }; + const rawConfig = configMock.create(); + rawConfig.get.mockImplementation((path) => { + if (path === 'savedObjects.permission.enabled') { + return true; + } else { + return false; + } + }); + 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', + permissions: 'f3ad308fa2a0c34007eb9ad461d6294a', + references: '7997cf5a56cc02bdc9c93361bde732b0', + type: '2f4316de49999235636386fe51dc06c1', + updated_at: '00da57df13e94e9d98437d13ace4bfe0', + }, + }, + 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' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, + }, + }, + settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, + }, + index: '.kibana_1', + }); + }); + 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); + rawConfig.get.mockImplementation((path) => { + if (path === 'workspace.enabled') { + return true; + } else { + return false; + } + }); testOpts.opensearchDashboardsRawConfig = rawConfig; withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); 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 b0350a00b211..e65effdd8eaa 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 @@ -78,8 +78,14 @@ describe('OpenSearchDashboardsMigrator', () => { expect(mappings).toMatchSnapshot(); }); + it('permissions field exists in the mappings when the feature is enabled', () => { + const options = mockOptions(false, true); + const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); + expect(mappings).toHaveProperty('properties.permissions'); + }); + it('workspaces field exists in the mappings when the feature is enabled', () => { - const options = mockOptions(true); + const options = mockOptions(true, false); const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); expect(mappings).toHaveProperty('properties.workspaces'); }); @@ -153,12 +159,29 @@ type MockedOptions = OpenSearchDashboardsMigratorOptions & { client: ReturnType; }; -const mockOptions = (isWorkspaceEnabled?: boolean) => { +const mockOptions = (isWorkspaceEnabled?: boolean, isPermissionControlEnabled?: boolean) => { const rawConfig = configMock.create(); rawConfig.get.mockReturnValue(false); - if (isWorkspaceEnabled) { + if (isWorkspaceEnabled || isPermissionControlEnabled) { rawConfig.get.mockReturnValue(true); } + rawConfig.get.mockImplementation((path) => { + if (path === 'savedObjects.permission.enabled') { + if (isPermissionControlEnabled) { + return true; + } else { + return false; + } + } else if (path === 'workspace.enabled') { + if (isWorkspaceEnabled) { + return true; + } else { + return false; + } + } else { + return false; + } + }); const options: MockedOptions = { logger: loggingSystemMock.create().get(), opensearchDashboardsVersion: '8.2.3', diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts new file mode 100644 index 000000000000..184c10a36aaa --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,348 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Principals, Permissions, ACL } from './acl'; + +describe('acl', () => { + it('test has permission', () => { + const principals: Principals = { + users: ['user1'], + groups: [], + }; + const permissions: Permissions = { + read: principals, + }; + const acl = new ACL(permissions); + expect( + acl.hasPermission(['read'], { + users: ['user1'], + groups: [], + }) + ).toEqual(true); + + expect( + acl.hasPermission(['read'], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + + expect( + acl.hasPermission([], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + + const nullValue: unknown = undefined; + expect(acl.hasPermission(['read'], nullValue as Principals)).toEqual(false); + expect(acl.hasPermission(['read'], {})).toEqual(false); + + acl.resetPermissions(); + expect(acl.hasPermission(['read'], nullValue as Principals)).toEqual(false); + expect(acl.hasPermission(['read'], {})).toEqual(false); + expect(acl.hasPermission(['read'], principals)).toEqual(false); + }); + + it('test add permission', () => { + const acl = new ACL(); + let result = acl + .addPermission(['read'], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + result = acl + .addPermission(['write', 'library_write'], { + users: ['user2'], + groups: ['group1', 'group2'], + }) + .getPermissions(); + expect(result?.write?.users).toEqual(['user2']); + expect(result?.library_write?.groups).toEqual(['group1', 'group2']); + + acl.resetPermissions(); + result = acl + .addPermission(['write', 'library_write'], { + users: ['user2'], + }) + .addPermission(['write', 'library_write'], { + groups: ['group1'], + }) + .getPermissions(); + expect(result?.write?.users).toEqual(['user2']); + expect(result?.write?.groups).toEqual(['group1']); + expect(result?.library_write?.users).toEqual(['user2']); + expect(result?.library_write?.groups).toEqual(['group1']); + + acl.resetPermissions(); + const nullValue: unknown = undefined; + result = acl.addPermission([], nullValue as Principals).getPermissions(); + expect(result).toEqual({}); + + acl.resetPermissions(); + result = acl.addPermission(nullValue as string[], {} as Principals).getPermissions(); + expect(result).toEqual({}); + }); + + it('test remove permission', () => { + let principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + let permissions = { + read: principals, + write: principals, + }; + let acl = new ACL(permissions); + let result = acl + .removePermission(['read'], { + users: ['user1'], + }) + .removePermission(['write'], { + groups: ['group2'], + }) + .removePermission(['write'], { + users: ['user3'], + groups: ['group3'], + }) + .removePermission(['library_write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result?.read?.users).toEqual([]); + expect(result?.write?.groups).toEqual(['group1']); + + principals = { + users: ['*'], + groups: ['*'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl + .removePermission(['read', 'write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result?.read?.users).toEqual(['*']); + expect(result?.write?.groups).toEqual(['*']); + + acl.resetPermissions(); + const nullValue: unknown = undefined; + result = acl.removePermission([], nullValue as Principals).getPermissions(); + expect(result).toEqual({}); + + acl.resetPermissions(); + result = acl.removePermission(nullValue as string[], principals).getPermissions(); + expect(result).toEqual({}); + }); + + it('test toFlatList', () => { + let principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + let permissions = { + read: principals, + write: principals, + }; + let acl = new ACL(permissions); + let result = acl.toFlatList(); + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + + acl.resetPermissions(); + principals = { + users: ['user1'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl.toFlatList(); + expect(result).toHaveLength(1); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + + acl.resetPermissions(); + principals = { + groups: ['group1', 'group2'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl.toFlatList(); + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + }); + + it('test generate query DSL', () => { + const nullValue: unknown = undefined; + let result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], nullValue as Principals); + expect(result).toEqual({ + query: { + match_none: {}, + }, + }); + + const principals = { + users: ['user1'], + groups: ['group1'], + }; + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(nullValue as string[], principals); + expect(result).toEqual({ + query: { + match_none: {}, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, 'workspace'); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace'], + }, + }, + ], + }, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, [ + 'workspace', + 'index-pattern', + ]); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace', 'index-pattern'], + }, + }, + ], + }, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts new file mode 100644 index 000000000000..769304fe8736 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,337 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} + +export interface Principals { + users?: string[]; + groups?: string[]; +} + +export type Permissions = Record; + +export interface TransformedPermission { + type: string; + name: string; + permissions: string[]; +} + +const addToPrincipals = ({ + principals = {}, + users, + groups, +}: { + principals: Principals; + users?: string[]; + groups?: string[]; +}) => { + if (users) { + if (!principals.users) { + principals.users = []; + } + principals.users = Array.from(new Set([...principals.users, ...users])); + } + if (groups) { + if (!principals.groups) { + principals.groups = []; + } + principals.groups = Array.from(new Set([...principals.groups, ...groups])); + } + return principals; +}; + +const deleteFromPrincipals = ({ + principals, + users, + groups, +}: { + principals?: Principals; + users?: string[]; + groups?: string[]; +}) => { + if (!principals) { + return principals; + } + if (users && principals.users) { + principals.users = principals.users.filter((item) => !users.includes(item)); + } + if (groups && principals.groups) { + principals.groups = principals.groups.filter((item) => !groups.includes(item)); + } + return principals; +}; + +const checkPermission = ( + allowedPrincipals: Principals | undefined, + requestedPrincipals: Principals +) => { + return ( + (allowedPrincipals?.users && + requestedPrincipals?.users && + checkPermissionForSinglePrincipalType(allowedPrincipals.users, requestedPrincipals.users)) || + (allowedPrincipals?.groups && + requestedPrincipals?.groups && + checkPermissionForSinglePrincipalType(allowedPrincipals.groups, requestedPrincipals.groups)) + ); +}; + +const checkPermissionForSinglePrincipalType = ( + allowedPrincipalArray: string[], + requestedPrincipalArray: string[] +) => { + return ( + allowedPrincipalArray && + requestedPrincipalArray && + (allowedPrincipalArray.includes('*') || + requestedPrincipalArray.some((item) => allowedPrincipalArray.includes(item))) + ); +}; + +export class ACL { + private permissions?: Permissions; + constructor(initialPermissions?: Permissions) { + this.permissions = initialPermissions || {}; + } + + /** + * A function that parses the permissions object to check whether the specific principal has the specific permission types or not + * + * @param {Array} permissionTypes permission types + * @param {Object} principals the users or groups + * @returns true if the principal has the specified permission types, false if the principal has no permission + * + * @public + */ + public hasPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || permissionTypes.length === 0 || !this.permissions || !principals) { + return false; + } + + const currentPermissions = this.permissions; + return permissionTypes.some((permissionType) => + checkPermission(currentPermissions[permissionType], principals) + ); + } + + /** + * A permissions object build function that adds principal with specific permission to the object + * + * This function is used to contruct a new permissions object or add principals with specified permissions to + * the existing permissions object. The usage is: + * + * const permissionObject = new ACL() + * .addPermission(['write', 'library_write'], { + * users: ['user2'], + * }) + * .addPermission(['write', 'library_write'], { + * groups: ['group1'], + * }) + * .getPermissions(); + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @returns the permissions object + * + * @public + */ + public addPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + this.permissions[permissionType] = addToPrincipals({ + principals: this.permissions[permissionType], + users: principals.users, + groups: principals.groups, + }); + } + + return this; + } + + /** + * A permissions object build function that removes specific permission of specific principal from the object + * + * This function is used to remove principals with specified permissions to + * the existing permissions object. The usage is: + * + * const newPermissionObject = new ACL() + * .removePermission(['write', 'library_write'], { + * users: ['user2'], + * }) + * .removePermission(['write', 'library_write'], { + * groups: ['group1'], + * }) + * .getPermissions(); + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @returns the permissions object + * + * @public + */ + public removePermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + const result = deleteFromPrincipals({ + principals: this.permissions![permissionType], + users: principals.users, + groups: principals.groups, + }); + if (result) { + this.permissions[permissionType] = result; + } + } + + return this; + } + + /** + * A function that transforms permissions format, change the format from permissionType->principals to principal->permissionTypes, + * which is used to clearyly dispaly user/group list and their granted permissions in the UI + * + * for example: + * the original permissions object is: { + * read: { + * users:['user1'] + * }, + * write:{ + * groups:['group1'] + * } + * } + * + * the transformed permissions object will be: [ + * {type:'users', name:'user1', permissions:['read']}, + * {type:'groups', name:'group1', permissions:['write']}, + * ] + * + * @returns the flat list of the permissions object + * + * @public + */ + public toFlatList(): TransformedPermission[] { + const result: TransformedPermission[] = []; + if (!this.permissions) { + return result; + } + + for (const permissionType in this.permissions) { + if (Object.prototype.hasOwnProperty.call(this.permissions, permissionType)) { + const { users = [], groups = [] } = this.permissions[permissionType] ?? {}; + users.forEach((user) => { + const found = result.find((r) => r.type === PrincipalType.Users && r.name === user); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Users, name: user, permissions: [permissionType] }); + } + }); + groups.forEach((group) => { + const found = result.find((r) => r.type === PrincipalType.Groups && r.name === group); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Groups, name: group, permissions: [permissionType] }); + } + }); + } + } + + return result; + } + + /** + * A permissions object build function that resets the permissions object + * + * @public + */ + public resetPermissions() { + // reset permissions + this.permissions = {}; + } + + /** + * A function that gets the premissions object + * + * @public + */ + public getPermissions() { + return this.permissions; + } + + /** + * A function that generates query DSL by the specific conditions, used for fetching saved objects from the saved objects index + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @param {String | Array} savedObjectType saved object type, such as workspace, index-pattern etc. + * @returns the generated query DSL + * + * @public + * @static + */ + public static generateGetPermittedSavedObjectsQueryDSL( + permissionTypes: string[], + principals: Principals, + savedObjectType?: string | string[] + ) { + if (!principals || !permissionTypes) { + return { + query: { + match_none: {}, + }, + }; + } + + const bool: any = { + filter: [], + }; + const subBool: any = { + should: [], + }; + + permissionTypes.forEach((permissionType) => { + Object.entries(principals).forEach(([principalType, principalsInCurrentType]) => { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + `.${principalType}`]: principalsInCurrentType, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + `.${principalType}`]: '*', + }, + }); + }); + }); + + bool.filter.push({ + bool: subBool, + }); + + if (savedObjectType) { + bool.filter.push({ + terms: { + type: Array.isArray(savedObjectType) ? savedObjectType : [savedObjectType], + }, + }); + } + + return { query: { bool } }; + } +} diff --git a/src/core/server/saved_objects/permission_control/index.ts b/src/core/server/saved_objects/permission_control/index.ts new file mode 100644 index 000000000000..f0e41a125b1c --- /dev/null +++ b/src/core/server/saved_objects/permission_control/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ACL, Permissions, Principals, PrincipalType, TransformedPermission } from './acl'; diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 291350bf93a6..e6ffaefb8a59 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -49,6 +49,9 @@ export const savedObjectsConfig = { schema: schema.object({ maxImportPayloadBytes: schema.byteSize({ defaultValue: 26214400 }), maxImportExportSize: schema.byteSize({ defaultValue: 10000 }), + permission: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }; diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 75b0d756f0cf..02eaff20331c 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -41,7 +41,7 @@ import { errors as opensearchErrors } from '@opensearch-project/opensearch'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; -import { Env } from '../config'; +import { Config, Env, ObjectToConfigAdapter } from '../config'; import { configServiceMock, savedObjectsRepositoryMock } from '../mocks'; import { opensearchServiceMock } from '../opensearch/opensearch_service.mock'; import { opensearchClientMock } from '../opensearch/client/mocks'; @@ -70,6 +70,13 @@ describe('SavedObjectsService', () => { maxImportExportSize: new ByteSizeValue(0), }); }); + const config$ = new BehaviorSubject( + new ObjectToConfigAdapter({ + savedObjects: { permission: { enabled: true } }, + }) + ); + + configService.getConfig$.mockReturnValue(config$); return mockCoreContext.create({ configService, env }); }; diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 5c3e22ac646a..9aa6aca713f0 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId, workspaces } = _source; + const { type, namespace, namespaces, originId, workspaces, permissions } = _source; const version = _seq_no != null || _primary_term != null @@ -86,6 +86,7 @@ export class SavedObjectsSerializer { ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), + ...(permissions && { permissions }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -114,6 +115,7 @@ export class SavedObjectsSerializer { version, references, workspaces, + permissions, } = savedObj; const source = { [type]: attributes, @@ -125,6 +127,7 @@ export class SavedObjectsSerializer { ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 473a63cf65f4..f882596ce529 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -28,6 +28,7 @@ * under the License. */ +import { Permissions } from '../permission_control'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -71,6 +72,7 @@ interface SavedObjectDoc { updated_at?: string; originId?: string; workspaces?: string[]; + permissions?: Permissions; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index e50332ae514a..b793046d9a94 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -168,7 +168,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, permissions }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -183,6 +183,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), ...(originId && { originId }), + ...(permissions && { permissions }), type, [type]: { title: 'Testing' }, references, @@ -444,25 +445,36 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; const namespace = 'foo-namespace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const workspace = 'foo-workspace'; const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ - create: { - _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, - _source: { - [type]: attributes, - type, - namespace, - ...(originId && { originId }), - references, - ...mockTimestampFields, - migrationVersion: migrationVersion || { [type]: '1.1.1' }, + items: objects.map( + ({ type, id, originId, attributes, references, migrationVersion, permissions }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(originId && { originId }), + ...(permissions && { permissions }), + references, + ...mockTimestampFields, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, + ...mockVersionProps, }, - ...mockVersionProps, - }, - })), + }) + ), }; }; @@ -732,6 +744,18 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction(objects, { method: 'create', getId }); }); + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkCreateSuccess(objects); + const expected = expect.objectContaining({ permissions }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); + it(`adds workspaces to request body for any types`, async () => { await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); const expected = expect.objectContaining({ workspaces: [workspace] }); @@ -1011,6 +1035,17 @@ describe('SavedObjectsRepository', () => { ); expect(result.saved_objects[1].id).toEqual(obj2.id); }); + + it(`includes permissions property if present`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + const result = await bulkCreateSuccess(objects); + expect(result).toEqual({ + saved_objects: [ + expect.objectContaining({ permissions }), + expect.objectContaining({ permissions }), + ], + }); + }); }); }); @@ -1230,6 +1265,22 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const obj = { id: 'three', type: MULTI_NAMESPACE_TYPE, permissions: permissions }; + const result = await bulkGetSuccess([obj]); + expect(result).toEqual({ + saved_objects: [expect.objectContaining({ permissions: permissions })], + }); + }); }); }); @@ -1247,6 +1298,14 @@ describe('SavedObjectsRepository', () => { const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ @@ -1507,6 +1566,20 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess([{ ..._obj2, namespace }]); expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2); }); + + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkUpdateSuccess(objects); + const doc = { + doc: expect.objectContaining({ permissions }), + }; + const body = [expect.any(Object), doc, expect.any(Object), doc]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); }); describe('errors', () => { @@ -1699,6 +1772,14 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three', permissions: permissions }; + const result = await bulkUpdateSuccess([obj1, obj], {}, true); + expect(result).toEqual({ + saved_objects: [expect.anything(), expect.objectContaining({ permissions })], + }); + }); }); }); @@ -1854,6 +1935,14 @@ describe('SavedObjectsRepository', () => { id: '123', }, ]; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); @@ -2051,6 +2140,16 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ permissions }), + }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -2131,6 +2230,11 @@ describe('SavedObjectsRepository', () => { expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); + it(`adds permissions to body when providing permissions info`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expectMigrationArgs({ permissions }); + }); + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); expectMigrationArgs({ namespace }); @@ -2177,11 +2281,13 @@ describe('SavedObjectsRepository', () => { namespace, references, originId, + permissions, }); expect(result).toEqual({ type, id, originId, + permissions, ...mockTimestampFields, version: mockVersion, attributes, @@ -2971,7 +3077,7 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const originId = 'some-origin-id'; - const getSuccess = async (type, id, options, includeOriginId) => { + const getSuccess = async (type, id, options, includeOriginId, permissions) => { const response = getMockGetResponse( { type, @@ -2979,6 +3085,7 @@ describe('SavedObjectsRepository', () => { // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. ...(includeOriginId && { originId }), + ...(permissions && { permissions }), }, options?.namespace ); @@ -3129,6 +3236,21 @@ describe('SavedObjectsRepository', () => { const result = await getSuccess(type, id, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const result = await getSuccess(type, id, { namespace }, undefined, permissions); + expect(result).toMatchObject({ + permissions: permissions, + }); + }); }); }); @@ -3730,6 +3852,14 @@ describe('SavedObjectsRepository', () => { }, ]; const originId = 'some-origin-id'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { @@ -3906,6 +4036,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions when providing permissions info`, async () => { + await updateSuccess(type, id, attributes, { permissions }); + const expected = expect.objectContaining({ permissions }); + const body = { + doc: expected, + }; + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -4000,6 +4142,11 @@ describe('SavedObjectsRepository', () => { const result = await updateSuccess(type, id, attributes, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const result = await updateSuccess(type, id, attributes, { permissions }); + expect(result).toMatchObject({ permissions }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1a4feab322b3..5340008f06a6 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -244,6 +244,7 @@ export class SavedObjectsRepository { initialNamespaces, version, workspaces, + permissions, } = options; const namespace = normalizeNamespace(options.namespace); @@ -291,6 +292,7 @@ export class SavedObjectsRepository { updated_at: time, ...(Array.isArray(references) && { references }), ...(Array.isArray(workspaces) && { workspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -461,6 +463,7 @@ export class SavedObjectsRepository { references: object.references || [], originId: object.originId, ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), + ...(object.permissions && { permissions: object.permissions }), }) as SavedObjectSanitizedDoc ), }; @@ -987,7 +990,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + const { originId, updated_at: updatedAt, permissions } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1002,6 +1005,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(permissions && { permissions }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1030,7 +1034,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options; + const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: SavedObjectsRawDoc | undefined; @@ -1044,6 +1048,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( @@ -1081,6 +1086,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(permissions && { permissions }), references, attributes, }; @@ -1281,7 +1287,7 @@ export class SavedObjectsRepository { }; } - const { attributes, references, version, namespace: objectNamespace } = object; + const { attributes, references, version, namespace: objectNamespace, permissions } = object; if (objectNamespace === ALL_NAMESPACES_STRING) { return { @@ -1302,6 +1308,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); @@ -1454,7 +1461,7 @@ export class SavedObjectsRepository { )[0] as any; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, permissions } = documentToSave; if (error) { return { id, @@ -1473,6 +1480,7 @@ export class SavedObjectsRepository { version: encodeVersion(seqNo, primaryTerm), attributes, references, + ...(permissions && { permissions }), }; }), }; @@ -1765,7 +1773,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt, workspaces } = doc._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1785,6 +1793,7 @@ function getSavedObjectFromSource( attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + ...(permissions && { permissions }), }; } 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 d3edb0d98845..49ce55c824d3 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -39,6 +39,7 @@ import { SavedObjectsFindOptions, } from '../types'; import { SavedObjectsErrorHelpers } from './lib/errors'; +import { Permissions } from '../permission_control'; /** * @@ -72,6 +73,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * workspaces the new created objects belong to */ workspaces?: string[]; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** @@ -106,7 +109,7 @@ export interface SavedObjectsBulkCreateObject { * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick { + extends Pick { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ @@ -188,6 +191,8 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The OpenSearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index a683863d8df6..06d03f5f24c4 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -27,6 +27,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Permissions } from '../server/saved_objects/permission_control/acl'; /** * Don't use this type, it's simply a helper type for {@link SavedObjectAttribute} @@ -115,6 +116,8 @@ export interface SavedObject { originId?: string; /** Workspace(s) that this saved object exists in. */ workspaces?: string[]; + /** Permissions that this saved objects exists in. */ + permissions?: Permissions; } export interface SavedObjectError { From a51d238d9fee9ee07a1e6b83e34f3f3c13d224bb Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 10:05:15 -0800 Subject: [PATCH 12/18] correct typo (#6023) (#6029) --- release-notes/opensearch-dashboards.release-notes-1.3.15.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/opensearch-dashboards.release-notes-1.3.15.md b/release-notes/opensearch-dashboards.release-notes-1.3.15.md index c9fd349e3468..b20f3afb228d 100644 --- a/release-notes/opensearch-dashboards.release-notes-1.3.15.md +++ b/release-notes/opensearch-dashboards.release-notes-1.3.15.md @@ -1,4 +1,4 @@ -# Version 1.3.14 Release Notes +# Version 1.3.15 Release Notes ### 🛡 Security From 49d1649a94822a5273153cfe01ae222d556f3a69 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Tue, 5 Mar 2024 11:02:41 -0800 Subject: [PATCH 13/18] [MD] Expose picker using function in data source management plugin setup (#6030) * expose picker using function in plugin setup Signed-off-by: Lu Yu * add changelog and test Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu --- CHANGELOG.md | 1 + .../create_cluster_selector.test.tsx.snap | 216 ++++++++++++++++++ .../cluster_selector/cluster_selector.tsx | 2 +- .../create_cluster_selector.test.tsx | 40 ++++ .../create_cluster_selector.tsx | 11 + .../data_source_management/public/plugin.ts | 8 +- 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.test.tsx create mode 100644 src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f9a43f16b8..6b7cdfa591b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) - [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) +- [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) ### 🚞 Infrastructure diff --git a/src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap b/src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap new file mode 100644 index 000000000000..68698f7adabe --- /dev/null +++ b/src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`create cluster selector should render normally 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ + , + "container":
+ , + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/plugins/data_source_management/public/components/cluster_selector/cluster_selector.tsx b/src/plugins/data_source_management/public/components/cluster_selector/cluster_selector.tsx index 59f73f1b8795..36edb19c8053 100644 --- a/src/plugins/data_source_management/public/components/cluster_selector/cluster_selector.tsx +++ b/src/plugins/data_source_management/public/components/cluster_selector/cluster_selector.tsx @@ -16,7 +16,7 @@ export const LocalCluster: ClusterOption = { id: '', }; -interface ClusterSelectorProps { +export interface ClusterSelectorProps { savedObjectsClient: SavedObjectsClientContract; notifications: ToastsStart; onSelectedDataSource: (clusterOption: ClusterOption[]) => void; diff --git a/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.test.tsx b/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.test.tsx new file mode 100644 index 000000000000..9a5673d75af9 --- /dev/null +++ b/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { createClusterSelector } from './create_cluster_selector'; +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; +import React from 'react'; +import { render } from '@testing-library/react'; + +describe('create cluster selector', () => { + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + }); + + it('should render normally', () => { + const props = { + savedObjectsClient: client, + notifications: toasts, + onSelectedDataSource: jest.fn(), + disabled: false, + hideLocalCluster: false, + fullWidth: false, + }; + const TestComponent = createClusterSelector(); + const component = render(); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'description', 'title'], + perPage: 10000, + type: 'data-source', + }); + expect(toasts.addWarning).toBeCalledTimes(0); + }); +}); diff --git a/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx b/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx new file mode 100644 index 000000000000..02696e8747f9 --- /dev/null +++ b/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { ClusterSelector, ClusterSelectorProps } from './cluster_selector'; + +export function createClusterSelector() { + return (props: ClusterSelectorProps) => ; +} diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 131e38a9765c..d6144759cbb9 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -7,6 +7,7 @@ import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { PLUGIN_NAME } from '../common'; +import { createClusterSelector } from './components/cluster_selector/create_cluster_selector'; import { ManagementSetup } from '../../management/public'; import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; @@ -17,6 +18,7 @@ import { AuthenticationMethodRegistery, } from './auth_registry'; import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod } from './types'; +import { ClusterSelectorProps } from './components/cluster_selector/cluster_selector'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -26,6 +28,7 @@ export interface DataSourceManagementSetupDependencies { export interface DataSourceManagementPluginSetup { registerAuthenticationMethod: (authMethodValues: AuthenticationMethod) => void; + getDataSourcePicker: React.ComponentType; } export interface DataSourceManagementPluginStart { @@ -91,7 +94,10 @@ export class DataSourceManagementPlugin registerAuthenticationMethod(sigV4AuthMethod); } - return { registerAuthenticationMethod }; + return { + registerAuthenticationMethod, + getDataSourcePicker: createClusterSelector(), + }; } public start(core: CoreStart) { From bb8155a923b319478a78421932d6d6184c9ba039 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 5 Mar 2024 13:49:09 -0800 Subject: [PATCH 14/18] [Discover] Fix lazy loading (#6041) * adds callback ref to lazy loading sentinel --------- Signed-off-by: Ashwin P Chandran --- CHANGELOG.md | 1 + .../default_discover_table.tsx | 41 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7cdfa591b6..ae9703a5de20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) - [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) +- [Discover] Fix lazy loading of the legacy table from getting stuck ([#6041](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6041)) - [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) ### 🚞 Infrastructure diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx index d563f1c1d098..21fc1f3670da 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx +++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx @@ -5,7 +5,7 @@ import './_doc_table.scss'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { EuiButtonEmpty, EuiCallOut, EuiProgress } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import { TableHeader } from './table_header'; @@ -70,33 +70,34 @@ export const LegacyDiscoverTable = ({ endRow: rows.length < pageSize ? rows.length : pageSize, }); const observerRef = useRef(null); - const sentinelRef = useRef(null); - - const loadMoreRows = () => { - setRenderedRowCount((prevRowCount) => prevRowCount + 50); // Load 50 more rows - }; + const [sentinelEle, setSentinelEle] = useState(); + // Need a callback ref since the element isnt set on the first render. + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setSentinelEle(node); + } + }, []); useEffect(() => { - const sentinel = sentinelRef.current; - observerRef.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - loadMoreRows(); - } - }, - { threshold: 1.0 } - ); + if (sentinelEle) { + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setRenderedRowCount((prevRowCount) => prevRowCount + 50); // Load 50 more rows + } + }, + { threshold: 1.0 } + ); - if (sentinelRef.current) { - observerRef.current.observe(sentinelRef.current); + observerRef.current.observe(sentinelEle); } return () => { - if (observerRef.current && sentinel) { - observerRef.current.unobserve(sentinel); + if (observerRef.current && sentinelEle) { + observerRef.current.unobserve(sentinelEle); } }; - }, []); + }, [sentinelEle]); const [activePage, setActivePage] = useState(0); const pageCount = Math.ceil(rows.length / pageSize); From 70adcc96869c2add8324b197b83c8db9a8644247 Mon Sep 17 00:00:00 2001 From: Yibo Wang <109543558+yibow98@users.noreply.github.com> Date: Tue, 5 Mar 2024 15:03:08 -0800 Subject: [PATCH 15/18] Create a migration function for datasource to add migrationVersion field (#6025) This PR is to add a migration function with version 2.4.0 for datasource to add a migrationVersion field. For more information, please refer to the RFC: #6022 Issues Resolved #6022 Signed-off-by: Yibo Wang --- CHANGELOG.md | 1 + .../server/saved_objects/data_source.test.ts | 29 +++++++++++++++++++ .../server/saved_objects/data_source.ts | 11 ++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/plugins/data_source/server/saved_objects/data_source.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9703a5de20..b22b5556ac33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) +- [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6022](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025) ### 🚞 Infrastructure diff --git a/src/plugins/data_source/server/saved_objects/data_source.test.ts b/src/plugins/data_source/server/saved_objects/data_source.test.ts new file mode 100644 index 000000000000..d465ee2a575b --- /dev/null +++ b/src/plugins/data_source/server/saved_objects/data_source.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { migrateDataSource } from './data_source'; +import { savedObjectsServiceMock } from '../../../../core/server/mocks'; + +const contextMock = savedObjectsServiceMock.createMigrationContext(); + +describe('migrateDataSource Function', () => { + it('should return the input document unchanged', () => { + const mockDoc = { + id: 'test-id', + type: 'test-type', + attributes: { + name: 'Test Name', + description: 'Test Description', + }, + references: [], + }; + + // Call the migrateDataSource function with the mock document + const result = migrateDataSource(mockDoc, contextMock); + + // Expect the result to be deeply equal to the mock document + expect(result).toEqual(mockDoc); + }); +}); diff --git a/src/plugins/data_source/server/saved_objects/data_source.ts b/src/plugins/data_source/server/saved_objects/data_source.ts index 9404a4bcf371..3f31e7bd14b7 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -3,7 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObjectsType } from 'opensearch-dashboards/server'; +import { flow } from 'lodash'; +import { SavedObjectMigrationFn, SavedObjectsType } from 'opensearch-dashboards/server'; + +// create a migration function which return the doc without any changes +export const migrateDataSource: SavedObjectMigrationFn = (doc) => ({ + ...doc, +}); export const dataSource: SavedObjectsType = { name: 'data-source', @@ -34,4 +40,7 @@ export const dataSource: SavedObjectsType = { }, }, }, + migrations: { + '2.4.0': flow(migrateDataSource), // 2.4.0 is the version that introduces the datasource + }, }; From 883925692ee65bd6b1a1c1453a6beda14c5b3ef4 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Wed, 6 Mar 2024 11:28:47 -0800 Subject: [PATCH 16/18] [MD] Change cluster selector component name to data source selector (#6042) * change component name Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu --- CHANGELOG.md | 1 + .../create_cluster_selector.tsx | 11 ------ ...create_data_source_selector.test.tsx.snap} | 6 +-- .../data_source_selector.test.tsx.snap} | 12 +++--- .../create_data_source_selector.test.tsx} | 6 +-- .../create_data_source_selector.tsx | 11 ++++++ .../data_source_selector.test.tsx} | 12 +++--- .../data_source_selector.tsx} | 39 ++++++++++--------- .../index.ts | 2 +- .../data_source_management/public/index.ts | 2 +- .../data_source_management/public/plugin.ts | 8 ++-- src/plugins/dev_tools/public/application.tsx | 6 +-- src/plugins/dev_tools/public/index.scss | 2 +- .../components/tutorial_directory.js | 6 +-- .../__snapshots__/flyout.test.tsx.snap | 4 +- .../objects_table/components/flyout.tsx | 4 +- 16 files changed, 68 insertions(+), 64 deletions(-) delete mode 100644 src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx rename src/plugins/data_source_management/public/components/{cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap => data_source_selector/__snapshots__/create_data_source_selector.test.tsx.snap} (97%) rename src/plugins/data_source_management/public/components/{cluster_selector/__snapshots__/cluster_selector.test.tsx.snap => data_source_selector/__snapshots__/data_source_selector.test.tsx.snap} (79%) rename src/plugins/data_source_management/public/components/{cluster_selector/create_cluster_selector.test.tsx => data_source_selector/create_data_source_selector.test.tsx} (85%) create mode 100644 src/plugins/data_source_management/public/components/data_source_selector/create_data_source_selector.tsx rename src/plugins/data_source_management/public/components/{cluster_selector/cluster_selector.test.tsx => data_source_selector/data_source_selector.test.tsx} (91%) rename src/plugins/data_source_management/public/components/{cluster_selector/cluster_selector.tsx => data_source_selector/data_source_selector.tsx} (67%) rename src/plugins/data_source_management/public/components/{cluster_selector => data_source_selector}/index.ts (57%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b22b5556ac33..0addb1e6d221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### 🛡 Security ### 📈 Features/Enhancements +- [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042)) ### 🐛 Bug Fixes diff --git a/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx b/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx deleted file mode 100644 index 02696e8747f9..000000000000 --- a/src/plugins/data_source_management/public/components/cluster_selector/create_cluster_selector.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { ClusterSelector, ClusterSelectorProps } from './cluster_selector'; - -export function createClusterSelector() { - return (props: ClusterSelectorProps) => ; -} diff --git a/src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/create_data_source_selector.test.tsx.snap similarity index 97% rename from src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap rename to src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/create_data_source_selector.test.tsx.snap index 68698f7adabe..4ce6e1bc30ab 100644 --- a/src/plugins/data_source_management/public/components/cluster_selector/__snapshots__/create_cluster_selector.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/create_data_source_selector.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`create cluster selector should render normally 1`] = ` +exports[`create data source selector should render normally 1`] = ` Object { "asFragment": [Function], "baseElement": @@ -10,7 +10,7 @@ Object { aria-haspopup="listbox" aria-label="Select a data source" class="euiComboBox euiComboBox--compressed" - data-test-subj="clusterSelectorComboBox" + data-test-subj="dataSourceSelectorComboBox" role="combobox" >