From 6f4d814951e8e424fe9a5226e8f63c1b0d6e6909 Mon Sep 17 00:00:00 2001 From: Xinrui Bai-amazon <139305463+xinruiba@users.noreply.github.com> Date: Fri, 8 Mar 2024 11:47:48 -0800 Subject: [PATCH 1/4] [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry. (#6049) * [Token Exchange Unification] Send registered crendentail field for dataSource creation and dataSource edition Signed-off-by: Xinrui Bai * [UT] Add unit test for extractRegisteredAuthTypeCredentials newly defined in untils.ts Signed-off-by: Xinrui Bai * [UT] add more test cases for util.ts Signed-off-by: Xinrui Bai * [UT] Add test cases for datasource creation form and datasource edition form Signed-off-by: Xinrui Bai * [Token Exchange Unification + UT] Button create-data-source and save-changes enable / disable control and unit test cases Signed-off-by: Xinrui Bai * Update changelog file Signed-off-by: Xinrui Bai * Addressing comments Signed-off-by: Xinrui Bai --------- Signed-off-by: Xinrui Bai --- CHANGELOG.md | 2 +- .../create_data_source_form.test.tsx | 52 ++++++++ .../create_form/create_data_source_form.tsx | 40 +++++-- .../edit_form/edit_data_source_form.test.tsx | 84 ++++++++++++- .../edit_form/edit_data_source_form.tsx | 21 +++- .../public/components/utils.test.ts | 111 +++++++++++++++++- .../public/components/utils.ts | 16 +++ .../datasource_form_validation.test.ts | 60 ++++++++-- .../validation/datasource_form_validation.ts | 27 +++-- .../data_source_management/public/mocks.ts | 14 ++- .../data_source_management/public/types.ts | 10 +- 11 files changed, 400 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 979d6e02f20..bc285435f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Handles auth methods from auth registry in DataSource SavedObjects Client Wrapper ([#6062](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6062)) - [Multiple Datasource] Expose a few properties for customize the appearance of the data source selector component ([#6057](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6057)) - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) - +- [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) ### 🐛 Bug Fixes 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 1040a17584a..043f8026784 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 @@ -369,6 +369,13 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ const mockSubmitHandler = jest.fn(); const mockTestConnectionHandler = jest.fn(); const mockCancelHandler = jest.fn(); + const changeTextFieldValue = (testSubjId: string, value: string) => { + component.find(testSubjId).last().simulate('change', { + target: { + value, + }, + }); + }; 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(); @@ -517,4 +524,49 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ expect(mockCredentialForm).not.toHaveBeenCalled(); }); }); + + test('should create data source with registered Auth when all fields are valid', () => { + const mockCredentialForm = jest.fn(); + const authMethodToBeTested = { + name: 'Some Auth Type', + credentialSourceOption: { + value: 'Some Auth Type', + inputDisplay: 'some input', + }, + credentialForm: mockCredentialForm, + crendentialFormField: { + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: 'some filled in password from registed auth credential form', + }, + } as AuthenticationMethod; + + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + changeTextFieldValue(titleIdentifier, 'test'); + changeTextFieldValue(descriptionIdentifier, 'test'); + changeTextFieldValue(endpointIdentifier, 'https://test.com'); + + findTestSubject(component, 'createDataSourceButton').simulate('click'); + + expect(mockSubmitHandler).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 25b082b8c6a..ab5df7220c7 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 @@ -39,7 +39,11 @@ import { isTitleValid, performDataSourceFormValidation, } from '../../../validation'; -import { getDefaultAuthMethod, isValidUrl } from '../../../utils'; +import { + extractRegisteredAuthTypeCredentials, + getDefaultAuthMethod, + isValidUrl, +} from '../../../utils'; export interface CreateDataSourceProps { existingDatasourceNamesList: string[]; @@ -55,8 +59,12 @@ export interface CreateDataSourceState { description: string; endpoint: string; auth: { - type: AuthType; - credentials: UsernamePasswordTypedContent | SigV4Content | undefined; + type: AuthType | string; + credentials: + | UsernamePasswordTypedContent + | SigV4Content + | { [key: string]: string } + | undefined; }; } @@ -102,7 +110,12 @@ export class CreateDataSourceForm extends React.Component< /* Validations */ isFormValid = () => { - return performDataSourceFormValidation(this.state, this.props.existingDatasourceNamesList, ''); + return performDataSourceFormValidation( + this.state, + this.props.existingDatasourceNamesList, + '', + this.authenticationMethodRegistery + ); }; /* Events */ @@ -298,22 +311,29 @@ export class CreateDataSourceForm extends React.Component< getFormValues = (): DataSourceAttributes => { let credentials = this.state.auth.credentials; - if (this.state.auth.type === AuthType.UsernamePasswordType) { + const authType = this.state.auth.type; + + if (authType === AuthType.NoAuth) { + credentials = {}; + } else if (authType === AuthType.UsernamePasswordType) { credentials = { username: this.state.auth.credentials.username, password: this.state.auth.credentials.password, } as UsernamePasswordTypedContent; - } - if (this.state.auth.type === AuthType.SigV4) { + } else if (authType === AuthType.SigV4) { credentials = { region: this.state.auth.credentials.region, accessKey: this.state.auth.credentials.accessKey, secretKey: this.state.auth.credentials.secretKey, service: this.state.auth.credentials.service || SigV4ServiceName.OpenSearch, } as SigV4Content; - } - if (this.state.auth.type === AuthType.NoAuth) { - credentials = {}; + } else { + const currentCredentials = (credentials ?? {}) as { [key: string]: string }; + credentials = extractRegisteredAuthTypeCredentials( + currentCredentials, + authType, + this.authenticationMethodRegistery + ); } return { 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 89d5c54cbc2..25ea22ef274 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 @@ -11,6 +11,7 @@ import { mockManagementPlugin, existingDatasourceNamesList, mockDataSourceAttributesWithNoAuth, + mockDataSourceAttributesWithRegisteredAuth, } from '../../../../mocks'; import { OpenSearchDashboardsContextProvider } from '../../../../../../opensearch_dashboards_react/public'; import { EditDataSourceForm } from './edit_data_source_form'; @@ -344,9 +345,25 @@ describe('Datasource Management: Edit Datasource Form', () => { describe('With Registered Authentication', () => { let component: ReactWrapper, React.Component<{}, {}, any>>; - const mockCredentialForm = jest.fn(); + const updateInputFieldAndBlur = ( + comp: ReactWrapper, React.Component<{}, {}, any>>, + fieldName: string, + updatedValue: string, + isTestSubj?: boolean + ) => { + const field = isTestSubj ? comp.find(fieldName) : comp.find({ name: fieldName }); + act(() => { + field.last().simulate('change', { target: { value: updatedValue } }); + }); + comp.update(); + act(() => { + field.last().simulate('focus').simulate('blur'); + }); + comp.update(); + }; test('should call registered crendential form', () => { + const mockedCredentialForm = jest.fn(); const authTypeToBeTested = 'Some Auth Type'; const authMethodToBeTest = { name: authTypeToBeTested, @@ -354,7 +371,7 @@ describe('With Registered Authentication', () => { value: authTypeToBeTested, inputDisplay: 'some input', }, - credentialForm: mockCredentialForm, + credentialForm: mockedCredentialForm, } as AuthenticationMethod; const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); @@ -380,6 +397,67 @@ describe('With Registered Authentication', () => { } ); - expect(mockCredentialForm).toHaveBeenCalled(); + expect(mockedCredentialForm).toHaveBeenCalled(); + }); + + test('should update the form with registered auth type on click save changes', async () => { + const mockedCredentialForm = jest.fn(); + const mockedSubmitHandler = jest.fn(); + const authMethodToBeTest = { + name: 'Some Auth Type', + credentialSourceOption: { + value: 'Some Auth Type', + inputDisplay: 'some input', + }, + credentialForm: mockedCredentialForm, + crendentialFormField: {}, + } as AuthenticationMethod; + + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTest); + + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + + await new Promise((resolve) => + setTimeout(() => { + updateInputFieldAndBlur(component, descriptionFieldIdentifier, ''); + expect( + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + resolve(); + }, 100) + ); + await new Promise((resolve) => + setTimeout(() => { + /* Updated description*/ + updateInputFieldAndBlur(component, descriptionFieldIdentifier, 'testDescription'); + expect( + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + + expect(component.find('[data-test-subj="datasource-edit-saveButton"]').exists()).toBe(true); + component.find('[data-test-subj="datasource-edit-saveButton"]').first().simulate('click'); + expect(mockedSubmitHandler).toHaveBeenCalled(); + resolve(); + }, 100) + ); }); }); 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 ee46af35531..0089e8f12af 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 @@ -44,7 +44,7 @@ import { } from '../../../validation'; import { UpdatePasswordModal } from '../update_password_modal'; import { UpdateAwsCredentialModal } from '../update_aws_credential_modal'; -import { getDefaultAuthMethod } from '../../../utils'; +import { extractRegisteredAuthTypeCredentials, getDefaultAuthMethod } from '../../../utils'; export interface EditDataSourceProps { existingDataSource: DataSourceAttributes; @@ -60,8 +60,12 @@ export interface EditDataSourceState { description: string; endpoint: string; auth: { - type: AuthType; - credentials: UsernamePasswordTypedContent | SigV4Content; + type: AuthType | string; + credentials: + | UsernamePasswordTypedContent + | SigV4Content + | { [key: string]: string } + | undefined; }; showUpdatePasswordModal: boolean; showUpdateAwsCredentialModal: boolean; @@ -155,7 +159,8 @@ export class EditDataSourceForm extends React.Component { expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe(AuthType.NoAuth); }); }); + + describe('Check extractRegisteredAuthTypeCredentials method', () => { + test('Should extract credential field successfully', () => { + const authTypeToBeTested = 'Some Auth Type'; + + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + crendentialFormField: { + userNameRegistered: '', + passWordRegistered: '', + }, + } as AuthenticationMethod; + + const mockedCredentialState = { + userName: 'some userName', + passWord: 'some password', + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: 'some filled in password from registed auth credential form', + } as { [key: string]: string }; + + const expectExtractedAuthCredentials = { + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: 'some filled in password from registed auth credential form', + }; + + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + + const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( + mockedCredentialState, + authTypeToBeTested, + authenticationMethodRegistery + ); + + expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); + }); + + test('Should extract empty object when no credentialFormField registered ', () => { + const authTypeToBeTested = 'Some Auth Type'; + + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + } as AuthenticationMethod; + + const mockedCredentialState = { + userName: 'some userName', + passWord: 'some password', + } as { [key: string]: string }; + + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + + const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( + mockedCredentialState, + authTypeToBeTested, + authenticationMethodRegistery + ); + + expect(deepEqual(registedAuthTypeCredentials, {})); + }); + + test('Should fill in empty value when credentail state not have registered field', () => { + const authTypeToBeTested = 'Some Auth Type'; + + const authMethodToBeTested = { + name: authTypeToBeTested, + credentialSourceOption: { + value: authTypeToBeTested, + inputDisplay: 'some input', + }, + crendentialFormField: { + userNameRegistered: '', + passWordRegistered: '', + }, + } as AuthenticationMethod; + + const mockedCredentialState = { + userName: 'some userName', + passWord: 'some password', + userNameRegistered: 'some filled in userName from registed auth credential form', + } as { [key: string]: string }; + + const expectExtractedAuthCredentials = { + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: '', + }; + + const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + + const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( + mockedCredentialState, + authTypeToBeTested, + authenticationMethodRegistery + ); + + expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 13709f60657..871abc1dc63 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -143,3 +143,19 @@ export const getDefaultAuthMethod = ( return initialSelectedAuthMethod; }; + +export const extractRegisteredAuthTypeCredentials = ( + currentCredentialState: { [key: string]: string }, + authType: string, + authenticationMethodRegistery: AuthenticationMethodRegistery +) => { + const registeredCredentials = {} as { [key: string]: string }; + const registeredCredentialField = + authenticationMethodRegistery.getAuthenticationMethod(authType)?.crendentialFormField ?? {}; + + Object.keys(registeredCredentialField).forEach((credentialFiled) => { + registeredCredentials[credentialFiled] = currentCredentialState[credentialFiled] ?? ''; + }); + + return registeredCredentials; +}; diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts index 9a00d0f29c9..fbac18c7ddc 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts @@ -8,9 +8,11 @@ import { CreateDataSourceState } from '../create_data_source_wizard/components/c import { EditDataSourceState } from '../edit_data_source/components/edit_form/edit_data_source_form'; import { defaultValidation, performDataSourceFormValidation } from './datasource_form_validation'; import { mockDataSourceAttributesWithAuth } from '../../mocks'; +import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../auth_registry'; describe('DataSourceManagement: Form Validation', () => { describe('validate create/edit datasource', () => { + let authenticationMethodRegistery = new AuthenticationMethodRegistery(); let form: CreateDataSourceState | EditDataSourceState = { formErrorsByField: { ...defaultValidation }, title: '', @@ -25,35 +27,40 @@ describe('DataSourceManagement: Form Validation', () => { }, }; test('should fail validation when title is empty', () => { - const result = performDataSourceFormValidation(form, [], ''); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); expect(result).toBe(false); }); test('should fail validation on duplicate title', () => { form.title = 'test'; - const result = performDataSourceFormValidation(form, ['oldTitle', 'test'], 'oldTitle'); + const result = performDataSourceFormValidation( + form, + ['oldTitle', 'test'], + 'oldTitle', + authenticationMethodRegistery + ); expect(result).toBe(false); }); test('should fail validation when endpoint is not valid', () => { form.endpoint = mockDataSourceAttributesWithAuth.endpoint; - const result = performDataSourceFormValidation(form, [], ''); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); expect(result).toBe(false); }); test('should fail validation when username is empty', () => { form.endpoint = 'test'; - const result = performDataSourceFormValidation(form, [], ''); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); expect(result).toBe(false); }); test('should fail validation when password is empty', () => { form.auth.credentials.username = 'test'; form.auth.credentials.password = ''; - const result = performDataSourceFormValidation(form, [], ''); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); expect(result).toBe(false); }); test('should NOT fail validation on empty username/password when No Auth is selected', () => { form.auth.type = AuthType.NoAuth; form.title = 'test'; form.endpoint = mockDataSourceAttributesWithAuth.endpoint; - const result = performDataSourceFormValidation(form, [], ''); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); expect(result).toBe(true); }); test('should NOT fail validation on all fields', () => { @@ -61,7 +68,46 @@ describe('DataSourceManagement: Form Validation', () => { const result = performDataSourceFormValidation( form, [mockDataSourceAttributesWithAuth.title], - mockDataSourceAttributesWithAuth.title + mockDataSourceAttributesWithAuth.title, + authenticationMethodRegistery + ); + expect(result).toBe(true); + }); + test('should NOT fail validation when registered auth type is selected and related credential field not empty', () => { + authenticationMethodRegistery = new AuthenticationMethodRegistery(); + const authMethodToBeTested = { + name: 'Some Auth Type', + credentialSourceOption: { + value: 'Some Auth Type', + inputDisplay: 'some input', + }, + credentialForm: jest.fn(), + crendentialFormField: { + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: 'some filled in password from registed auth credential form', + }, + } as AuthenticationMethod; + + authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + + const formWithRegisteredAuth: CreateDataSourceState | EditDataSourceState = { + formErrorsByField: { ...defaultValidation }, + title: 'test registered auth type', + description: '', + endpoint: 'https://test.com', + auth: { + type: 'Some Auth Type', + credentials: { + userNameRegistered: 'some filled in userName from registed auth credential form', + passWordRegistered: 'some filled in password from registed auth credential form', + }, + }, + }; + const result = performDataSourceFormValidation( + formWithRegisteredAuth, + [], + '', + authenticationMethodRegistery ); expect(result).toBe(true); }); diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts index aecf6e51730..2ae5585e181 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts @@ -4,10 +4,11 @@ */ import { i18n } from '@osd/i18n'; -import { isValidUrl } from '../utils'; +import { extractRegisteredAuthTypeCredentials, isValidUrl } from '../utils'; import { CreateDataSourceState } from '../create_data_source_wizard/components/create_form/create_data_source_form'; import { EditDataSourceState } from '../edit_data_source/components/edit_form/edit_data_source_form'; import { AuthType } from '../../types'; +import { AuthenticationMethodRegistery } from '../../auth_registry'; export interface CreateEditDataSourceValidation { title: string[]; @@ -68,7 +69,8 @@ export const isTitleValid = ( export const performDataSourceFormValidation = ( formValues: CreateDataSourceState | EditDataSourceState, existingDatasourceNamesList: string[], - existingTitle: string + existingTitle: string, + authenticationMethodRegistery: AuthenticationMethodRegistery ) => { /* Title validation */ const titleValid = isTitleValid(formValues?.title, existingDatasourceNamesList, existingTitle); @@ -84,8 +86,9 @@ export const performDataSourceFormValidation = ( /* Credential Validation */ - /* Username & Password */ - if (formValues?.auth?.type === AuthType.UsernamePasswordType) { + if (formValues?.auth?.type === AuthType.NoAuth) { + return true; + } else if (formValues?.auth?.type === AuthType.UsernamePasswordType) { /* Username */ if (!formValues.auth.credentials?.username) { return false; @@ -95,9 +98,7 @@ export const performDataSourceFormValidation = ( if (!formValues.auth.credentials?.password) { return false; } - } - /* AWS SigV4 Content */ - if (formValues?.auth?.type === AuthType.SigV4) { + } else if (formValues?.auth?.type === AuthType.SigV4) { /* Access key */ if (!formValues.auth.credentials?.accessKey) { return false; @@ -117,6 +118,18 @@ export const performDataSourceFormValidation = ( if (!formValues.auth.credentials?.service) { return false; } + } else { + const registeredCredentials = extractRegisteredAuthTypeCredentials( + (formValues?.auth?.credentials ?? {}) as { [key: string]: string }, + formValues?.auth?.type ?? '', + authenticationMethodRegistery + ); + + for (const credentialValue of Object.values(registeredCredentials)) { + if (credentialValue.trim().length === 0) { + return false; + } + } } return true; diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index 628cd368a91..2d539cf19e1 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -6,7 +6,7 @@ import React from 'react'; import { throwError } from 'rxjs'; import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; -import { AuthType } from './types'; +import { AuthType, DataSourceAttributes } from './types'; import { coreMock } from '../../../core/public/mocks'; import { DataSourceManagementPlugin, @@ -204,6 +204,18 @@ export const mockDataSourceAttributesWithNoAuth = { credentials: undefined, }, }; + +export const mockDataSourceAttributesWithRegisteredAuth = { + id: 'testRegisteredAuth', + title: 'create-test-ds-registered-auth', + description: 'jest testing', + endpoint: 'https://test.com', + auth: { + type: 'Some Auth Type', + credentials: {} as { [key: string]: string }, + }, +} as DataSourceAttributes; + export const getDataSourceByIdWithCredential = { attributes: { id: 'alpha-test', diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index b0dd6caf1a1..d8df61d304b 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -121,7 +121,7 @@ export const sigV4CredentialField = { region: '', accessKey: '', secretKey: '', - service: '', + service: SigV4ServiceName.OpenSearch, }; export const sigV4AuthMethod = { @@ -141,8 +141,12 @@ export interface DataSourceAttributes extends SavedObjectAttributes { description?: string; endpoint?: string; auth: { - type: AuthType; - credentials: UsernamePasswordTypedContent | SigV4Content | undefined; + type: AuthType | string; + credentials: + | UsernamePasswordTypedContent + | SigV4Content + | { [key: string]: string } + | undefined; }; } From 1c5ad6c79e3a51472992ba3a537331a12c25e8ad Mon Sep 17 00:00:00 2001 From: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> Date: Fri, 8 Mar 2024 14:30:27 -0800 Subject: [PATCH 2/4] [MDS] Support Vega Visualizations (#5975) * Add MDS support for Vega Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor field to data_source_id Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG.md Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Added test cases and renamed field to use data_source_name Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add prefix datasource name test case and add example in default hjson Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Move CHANGELOG to appropriate section Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Increased test coverage of search() method Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --------- Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --- CHANGELOG.md | 5 +- .../vis_type_vega/opensearch_dashboards.json | 2 +- .../data_model/opensearch_query_parser.ts | 2 +- .../public/data_model/search_api.test.ts | 159 ++++++++++++++++++ .../public/data_model/search_api.ts | 88 +++++++--- .../vis_type_vega/public/data_model/types.ts | 1 + .../vis_type_vega/public/default.spec.hjson | 4 + src/plugins/vis_type_vega/public/plugin.ts | 15 +- src/plugins/vis_type_vega/public/services.ts | 13 +- .../public/vega_request_handler.ts | 9 +- 10 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 src/plugins/vis_type_vega/public/data_model/search_api.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bc285435f30..ca45463f3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +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)) +- [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042)) - [Multiple Datasource] Add interfaces to register add-on authentication method from plug-in module ([#5851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5851)) - [Multiple Datasource] Able to Hide "Local Cluster" option from datasource DropDown ([#5827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5827)) - [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) @@ -26,6 +26,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Expose a few properties for customize the appearance of the data source selector component ([#6057](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6057)) - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) - [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) +- [Multiple Datasource] Add Vega support to MDS by specifying a data source name in the Vega spec ([#5975](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5975)) ### 🐛 Bug Fixes @@ -39,7 +40,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][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 ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) -- [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) +- [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/vis_type_vega/opensearch_dashboards.json b/src/plugins/vis_type_vega/opensearch_dashboards.json index faf10c831e6..6cff356125b 100644 --- a/src/plugins/vis_type_vega/opensearch_dashboards.json +++ b/src/plugins/vis_type_vega/opensearch_dashboards.json @@ -11,7 +11,7 @@ "inspector", "uiActions" ], - "optionalPlugins": ["home", "usageCollection"], + "optionalPlugins": ["home", "usageCollection", "dataSource"], "requiredBundles": [ "opensearchDashboardsUtils", "opensearchDashboardsReact", diff --git a/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts b/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts index f5da7e7b81d..8f559486390 100644 --- a/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts @@ -229,7 +229,7 @@ export class OpenSearchQueryParser { name: getRequestName(r, index), })); - const data$ = this._searchAPI.search(opensearchSearches); + const data$ = await this._searchAPI.search(opensearchSearches); const results = await data$.toPromise(); diff --git a/src/plugins/vis_type_vega/public/data_model/search_api.test.ts b/src/plugins/vis_type_vega/public/data_model/search_api.test.ts new file mode 100644 index 00000000000..7e756e7bdec --- /dev/null +++ b/src/plugins/vis_type_vega/public/data_model/search_api.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'opensearch-dashboards/public'; +import { SearchAPI, SearchAPIDependencies } from './search_api'; +import { ISearchStart } from 'src/plugins/data/public'; +import { IUiSettingsClient } from 'opensearch-dashboards/public'; + +jest.mock('rxjs', () => ({ + combineLatest: jest.fn().mockImplementation((obj) => obj), +})); + +jest.mock('../../../data/public', () => ({ + getSearchParamsFromRequest: jest.fn().mockImplementation((obj, _) => obj), +})); + +interface MockSearch { + params?: Record; + dataSourceId?: string; + pipe: () => {}; +} + +describe('SearchAPI.search', () => { + // This will only test that searchApiParams were correctly set. As such, every other function can be mocked + const getSearchAPI = (dataSourceEnabled: boolean) => { + const savedObjectsClient = {} as SavedObjectsClientContract; + + const searchStartMock = {} as ISearchStart; + searchStartMock.search = jest.fn().mockImplementation((obj, _) => { + const mockedSearchResults = {} as MockSearch; + mockedSearchResults.params = obj; + mockedSearchResults.pipe = jest.fn().mockReturnValue(mockedSearchResults.params); + return mockedSearchResults; + }); + + const uiSettings = {} as IUiSettingsClient; + uiSettings.get = jest.fn().mockReturnValue(0); + uiSettings.get.bind = jest.fn().mockReturnValue(0); + + const dependencies = { + savedObjectsClient, + dataSourceEnabled, + search: searchStartMock, + uiSettings, + } as SearchAPIDependencies; + const searchAPI = new SearchAPI(dependencies); + searchAPI.findDataSourceIdbyName = jest.fn().mockImplementation((name) => { + if (!dataSourceEnabled) { + throw new Error(); + } + if (name === 'exampleName') { + return Promise.resolve('some-id'); + } + }); + + return searchAPI; + }; + + test('If MDS is disabled and there is no datasource, return params without datasource id', async () => { + const searchAPI = getSearchAPI(false); + const requests = [{ name: 'example-id' }]; + const fetchParams = ((await searchAPI.search(requests)) as unknown) as MockSearch[]; + expect(fetchParams[0].params).toBe(requests[0]); + expect(fetchParams[0].hasOwnProperty('dataSourceId')).toBe(false); + }); + + test('If MDS is disabled and there is a datasource, it should throw an errorr', () => { + const searchAPI = getSearchAPI(false); + const requests = [{ name: 'example-id', data_source_name: 'non-existent-datasource' }]; + expect(searchAPI.search(requests)).rejects.toThrowError(); + }); + + test('If MDS is enabled and there is no datasource, return params without datasource id', async () => { + const searchAPI = getSearchAPI(true); + const requests = [{ name: 'example-id' }]; + const fetchParams = ((await searchAPI.search(requests)) as unknown) as MockSearch[]; + expect(fetchParams[0].params).toBe(requests[0]); + expect(fetchParams[0].hasOwnProperty('dataSourceId')).toBe(false); + }); + + test('If MDS is enabled and there is a datasource, return params with datasource id', async () => { + const searchAPI = getSearchAPI(true); + const requests = [{ name: 'example-id', data_source_name: 'exampleName' }]; + const fetchParams = ((await searchAPI.search(requests)) as unknown) as MockSearch[]; + expect(fetchParams[0].hasOwnProperty('params')).toBe(true); + expect(fetchParams[0].dataSourceId).toBe('some-id'); + }); +}); + +describe('SearchAPI.findDataSourceIdbyName', () => { + const savedObjectsClient = {} as SavedObjectsClientContract; + savedObjectsClient.find = jest.fn().mockImplementation((query: SavedObjectsFindOptions) => { + if (query.search === `"uniqueDataSource"`) { + return Promise.resolve({ + total: 1, + savedObjects: [{ id: 'some-datasource-id', attributes: { title: 'uniqueDataSource' } }], + }); + } else if (query.search === `"duplicateDataSource"`) { + return Promise.resolve({ + total: 2, + savedObjects: [ + { id: 'some-datasource-id', attributes: { title: 'duplicateDataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'duplicateDataSource' } }, + ], + }); + } else if (query.search === `"DataSource"`) { + return Promise.resolve({ + total: 2, + savedObjects: [ + { id: 'some-datasource-id', attributes: { title: 'DataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'DataSource Copy' } }, + ], + }); + } else { + return Promise.resolve({ + total: 0, + savedObjects: [], + }); + } + }); + + const getSearchAPI = (dataSourceEnabled: boolean) => { + const dependencies = { savedObjectsClient, dataSourceEnabled } as SearchAPIDependencies; + return new SearchAPI(dependencies); + }; + + test('If dataSource is disabled, throw error', () => { + const searchAPI = getSearchAPI(false); + expect(searchAPI.findDataSourceIdbyName('nonexistentDataSource')).rejects.toThrowError( + 'data_source_name cannot be used because data_source.enabled is false' + ); + }); + + test('If dataSource is enabled but no matching dataSourceName, then throw error', () => { + const searchAPI = getSearchAPI(true); + expect(searchAPI.findDataSourceIdbyName('nonexistentDataSource')).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "nonexistentDataSource" but got 0 results' + ); + }); + + test('If dataSource is enabled but multiple dataSourceNames, then throw error', () => { + const searchAPI = getSearchAPI(true); + expect(searchAPI.findDataSourceIdbyName('duplicateDataSource')).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "duplicateDataSource" but got 2 results' + ); + }); + + test('If dataSource is enabled but only one dataSourceName, then return id', async () => { + const searchAPI = getSearchAPI(true); + expect(await searchAPI.findDataSourceIdbyName('uniqueDataSource')).toBe('some-datasource-id'); + }); + + test('If dataSource is enabled and the dataSourceName is a prefix of another, ensure the prefix is only returned', async () => { + const searchAPI = getSearchAPI(true); + expect(await searchAPI.findDataSourceIdbyName('DataSource')).toBe('some-datasource-id'); + }); +}); diff --git a/src/plugins/vis_type_vega/public/data_model/search_api.ts b/src/plugins/vis_type_vega/public/data_model/search_api.ts index 7f3a333f84d..2c0b7cdd614 100644 --- a/src/plugins/vis_type_vega/public/data_model/search_api.ts +++ b/src/plugins/vis_type_vega/public/data_model/search_api.ts @@ -31,6 +31,8 @@ import { combineLatest } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { CoreStart, IUiSettingsClient } from 'opensearch-dashboards/public'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; import { getSearchParamsFromRequest, SearchRequest, @@ -45,6 +47,8 @@ export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; injectedMetadata: CoreStart['injectedMetadata']; search: DataPublicPluginStart['search']; + dataSourceEnabled: boolean; + savedObjectsClient: SavedObjectsClientContract; } export class SearchAPI { @@ -54,31 +58,75 @@ export class SearchAPI { public readonly inspectorAdapters?: VegaInspectorAdapters ) {} - search(searchRequests: SearchRequest[]) { + async search(searchRequests: SearchRequest[]) { const { search } = this.dependencies.search; const requestResponders: any = {}; return combineLatest( - searchRequests.map((request) => { - const requestId = request.name; - const params = getSearchParamsFromRequest(request, { - getConfig: this.dependencies.uiSettings.get.bind(this.dependencies.uiSettings), - }); - - if (this.inspectorAdapters) { - requestResponders[requestId] = this.inspectorAdapters.requests.start(requestId, request); - requestResponders[requestId].json(params.body); - } - - return search({ params }, { abortSignal: this.abortSignal }).pipe( - tap((data) => this.inspectSearchResult(data, requestResponders[requestId])), - map((data) => ({ - name: requestId, - rawResponse: data.rawResponse, - })) - ); - }) + await Promise.all( + searchRequests.map(async (request) => { + const requestId = request.name; + const dataSourceId = !!request.data_source_name + ? await this.findDataSourceIdbyName(request.data_source_name) + : undefined; + + const params = getSearchParamsFromRequest(request, { + getConfig: this.dependencies.uiSettings.get.bind(this.dependencies.uiSettings), + }); + + if (this.inspectorAdapters) { + requestResponders[requestId] = this.inspectorAdapters.requests.start( + requestId, + request + ); + requestResponders[requestId].json(params.body); + } + + const searchApiParams = + dataSourceId && this.dependencies.dataSourceEnabled + ? { params, dataSourceId } + : { params }; + + return search(searchApiParams, { abortSignal: this.abortSignal }).pipe( + tap((data) => this.inspectSearchResult(data, requestResponders[requestId])), + map((data) => ({ + name: requestId, + rawResponse: data.rawResponse, + })) + ); + }) + ) + ); + } + + async findDataSourceIdbyName(dataSourceName: string) { + if (!this.dependencies.dataSourceEnabled) { + throw new Error('data_source_name cannot be used because data_source.enabled is false'); + } + const dataSources = await this.dataSourceFindQuery(dataSourceName); + + // In the case that data_source_name is a prefix of another name, match exact data_source_name + const possibleDataSourceIds = dataSources.savedObjects.filter( + (obj) => obj.attributes.title === dataSourceName ); + + if (possibleDataSourceIds.length !== 1) { + throw new Error( + `Expected exactly 1 result for data_source_name "${dataSourceName}" but got ${possibleDataSourceIds.length} results` + ); + } + + return possibleDataSourceIds.pop()?.id; + } + + async dataSourceFindQuery(dataSourceName: string) { + return await this.dependencies.savedObjectsClient.find({ + type: 'data-source', + perPage: 10, + search: `"${dataSourceName}"`, + searchFields: ['title'], + fields: ['id', 'title'], + }); } public resetSearchStats() { diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts index 35198f846f0..4d0b2bb476d 100644 --- a/src/plugins/vis_type_vega/public/data_model/types.ts +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -186,6 +186,7 @@ export interface UrlObject { [CONSTANTS.TYPE]?: string; name?: string; index?: string; + data_source_name?: string; body?: Body; size?: number; timeout?: string; diff --git a/src/plugins/vis_type_vega/public/default.spec.hjson b/src/plugins/vis_type_vega/public/default.spec.hjson index b4e126bfa3d..457a6398b9d 100644 --- a/src/plugins/vis_type_vega/public/default.spec.hjson +++ b/src/plugins/vis_type_vega/public/default.spec.hjson @@ -29,6 +29,10 @@ // Which index to search index: _all + + // If "data_source.enabled: true", optionally set the data source name to query from (omit field if querying from local cluster) + // data_source_name: Example US Cluster + // Aggregate data by the time field into time buckets, counting the number of documents in each bucket. body: { aggs: { diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 3967c535136..924cc36739a 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -28,6 +28,7 @@ * under the License. */ +import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; @@ -41,6 +42,8 @@ import { setUISettings, setMapsLegacyConfig, setInjectedMetadata, + setDataSourceEnabled, + setSavedObjectsClient, } from './services'; import { createVegaFn } from './expressions/vega_fn'; @@ -69,6 +72,7 @@ export interface VegaPluginSetupDependencies { visualizations: VisualizationsSetup; inspector: InspectorSetup; data: DataPublicPluginSetup; + dataSource?: DataSourcePluginSetup; mapsLegacy: any; } @@ -88,7 +92,14 @@ export class VegaPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { inspector, data, expressions, visualizations, mapsLegacy }: VegaPluginSetupDependencies + { + inspector, + data, + expressions, + visualizations, + mapsLegacy, + dataSource, + }: VegaPluginSetupDependencies ) { setInjectedVars({ enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, @@ -96,6 +107,7 @@ export class VegaPlugin implements Plugin, void> { }); setUISettings(core.uiSettings); setMapsLegacyConfig(mapsLegacy.config); + setDataSourceEnabled({ enabled: !!dataSource }); const visualizationDependencies: Readonly = { core, @@ -116,6 +128,7 @@ export class VegaPlugin implements Plugin, void> { public start(core: CoreStart, { data, uiActions }: VegaPluginStartDependencies) { setNotifications(core.notifications); setData(data); + setSavedObjectsClient(core.savedObjects); setUiActions(uiActions); setInjectedMetadata(core.injectedMetadata); } diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index b67a0959c63..36a417b3038 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -28,7 +28,12 @@ * under the License. */ -import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/public'; +import { + CoreStart, + NotificationsStart, + IUiSettingsClient, + SavedObjectsStart, +} from 'src/core/public'; import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; @@ -36,6 +41,12 @@ import { MapsLegacyConfig } from '../../maps_legacy/config'; import { UiActionsStart } from '../../ui_actions/public'; export const [getData, setData] = createGetterSetter('Data'); +export const [getDataSourceEnabled, setDataSourceEnabled] = createGetterSetter<{ + enabled: boolean; +}>('DataSource'); +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter( + 'SavedObjects' +); export const [getNotifications, setNotifications] = createGetterSetter( 'Notifications' diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index 7711f5d0f49..0d5556a3e2f 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -35,7 +35,12 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './expressions/vega_fn'; -import { getData, getInjectedMetadata } from './services'; +import { + getData, + getDataSourceEnabled, + getInjectedMetadata, + getSavedObjectsClient, +} from './services'; import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { @@ -70,6 +75,8 @@ export function createVegaRequestHandler( uiSettings, search: getData().search, injectedMetadata: getInjectedMetadata(), + dataSourceEnabled: getDataSourceEnabled().enabled, + savedObjectsClient: getSavedObjectsClient().client, }, context.abortSignal, context.inspectorAdapters From 58fb588f34ee932b5f51fcb17bacb368c766a69b Mon Sep 17 00:00:00 2001 From: Tianle Huang <60111637+tianleh@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:18:10 -0800 Subject: [PATCH 3/4] Support dynamic CSP rules to mitigate clickjacking (#5641) * support dynamic csp rules to mitigate clickjacking Signed-off-by: Tianle Huang * add unit tests for the provider class Signed-off-by: Tianle Huang * move request handler to its own class Signed-off-by: Tianle Huang * add license headers Signed-off-by: Tianle Huang * fix failed unit tests Signed-off-by: Tianle Huang * add unit tests for the handler Signed-off-by: Tianle Huang * add content to read me Signed-off-by: Tianle Huang * fix test error Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * update CHANGELOG.md Signed-off-by: Tianle Huang * update snap tests Signed-off-by: Tianle Huang * update snapshots Signed-off-by: Tianle Huang * fix a wrong import Signed-off-by: Tianle Huang * undo changes in listing snap Signed-off-by: Tianle Huang * improve wording Signed-off-by: Tianle Huang * set client after default client is created Signed-off-by: Tianle Huang * update return value and add a unit test Signed-off-by: Tianle Huang * remove unnecessary dependency Signed-off-by: Tianle Huang * make the name of the index configurable Signed-off-by: Tianle Huang * expose APIs and update file structures Signed-off-by: Tianle Huang * add header Signed-off-by: Tianle Huang * fix link error Signed-off-by: Tianle Huang * fix link error Signed-off-by: Tianle Huang * add more unit tests Signed-off-by: Tianle Huang * add more unit tests Signed-off-by: Tianle Huang * update api path Signed-off-by: Tianle Huang * remove logging Signed-off-by: Tianle Huang * update path Signed-off-by: Tianle Huang * rename index name Signed-off-by: Tianle Huang * update wording Signed-off-by: Tianle Huang * make the new plugin disabled by default Signed-off-by: Tianle Huang * do not update defaults to avoid breaking change Signed-off-by: Tianle Huang * update readme to reflect new API path Signed-off-by: Tianle Huang * update handler to append frame-ancestors conditionally Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * clean up code to prepare for application config Signed-off-by: Tianle Huang * reset change log Signed-off-by: Tianle Huang * reset change log again Signed-off-by: Tianle Huang * update accordingly to new changes in applicationConfig Signed-off-by: Tianle Huang * update changelog Signed-off-by: Tianle Huang * rename to a new plugin name Signed-off-by: Tianle Huang * rename Signed-off-by: Tianle Huang * rename more Signed-off-by: Tianle Huang * sync changelog from main Signed-off-by: Tianle Huang * onboard to app config Signed-off-by: Tianle Huang * fix comment Signed-off-by: Tianle Huang * update yml Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * update change log Signed-off-by: Tianle Huang * call out single quotes in readme Signed-off-by: Tianle Huang * update yml Signed-off-by: Tianle Huang * update default Signed-off-by: Tianle Huang * add reference link Signed-off-by: Tianle Huang * update js doc Signed-off-by: Tianle Huang * rename Signed-off-by: Tianle Huang * use new name Signed-off-by: Tianle Huang * redo changelog update Signed-off-by: Tianle Huang * remove link Signed-off-by: Tianle Huang * better name Signed-off-by: Tianle Huang --------- Signed-off-by: Tianle Huang --- CHANGELOG.md | 1 + config/opensearch_dashboards.yml | 4 + .../application_config/server/index.ts | 6 +- src/plugins/csp_handler/README.md | 51 ++++ src/plugins/csp_handler/common/index.ts | 7 + src/plugins/csp_handler/config.ts | 12 + .../csp_handler/opensearch_dashboards.json | 11 + .../csp_handler/server/csp_handlers.test.ts | 273 ++++++++++++++++++ .../csp_handler/server/csp_handlers.ts | 85 ++++++ src/plugins/csp_handler/server/index.ts | 22 ++ src/plugins/csp_handler/server/plugin.ts | 42 +++ src/plugins/csp_handler/server/types.ts | 15 + 12 files changed, 528 insertions(+), 1 deletion(-) create mode 100755 src/plugins/csp_handler/README.md create mode 100644 src/plugins/csp_handler/common/index.ts create mode 100644 src/plugins/csp_handler/config.ts create mode 100644 src/plugins/csp_handler/opensearch_dashboards.json create mode 100644 src/plugins/csp_handler/server/csp_handlers.test.ts create mode 100644 src/plugins/csp_handler/server/csp_handlers.ts create mode 100644 src/plugins/csp_handler/server/index.ts create mode 100644 src/plugins/csp_handler/server/plugin.ts create mode 100644 src/plugins/csp_handler/server/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca45463f3f3..5b07ea1dde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Deprecations ### 🛡 Security +- Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 ### 📈 Features/Enhancements - [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042)) diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index b2710ad4cba..99df1d808ba 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -36,6 +36,10 @@ # Set the value of this setting to true to enable plugin application config. By default it is disabled. # application_config.enabled: false +# Set the value of this setting to true to enable plugin CSP handler. By default it is disabled. +# It requires the application config plugin as its dependency. +# csp_handler.enabled: false + # The default application to load. #opensearchDashboards.defaultAppId: "home" diff --git a/src/plugins/application_config/server/index.ts b/src/plugins/application_config/server/index.ts index 1ef2bbc3baf..3eb85b455af 100644 --- a/src/plugins/application_config/server/index.ts +++ b/src/plugins/application_config/server/index.ts @@ -20,4 +20,8 @@ export function plugin(initializerContext: PluginInitializerContext) { return new ApplicationConfigPlugin(initializerContext); } -export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types'; +export { + ApplicationConfigPluginSetup, + ApplicationConfigPluginStart, + ConfigurationClient, +} from './types'; diff --git a/src/plugins/csp_handler/README.md b/src/plugins/csp_handler/README.md new file mode 100755 index 00000000000..04a6ca34f0d --- /dev/null +++ b/src/plugins/csp_handler/README.md @@ -0,0 +1,51 @@ +# CspHandler + +A OpenSearch Dashboards plugin + +This plugin is to support updating Content Security Policy (CSP) rules dynamically without requiring a server restart. It registers a pre-response handler to `HttpServiceSetup` which can get CSP rules from a dependent plugin `applicationConfig` and then rewrite to CSP header. Users are able to call the API endpoint exposed by the `applicationConfig` plugin directly, e.g through CURL. Currently there is no new OSD page for ease of user interactions with the APIs. Updates to the CSP rules will take effect immediately. As a comparison, modifying CSP rules through the key `csp.rules` in OSD YAML file would require a server restart. + +By default, this plugin is disabled. Once enabled, the plugin will first use what users have configured through `applicationConfig`. If not configured, it will check whatever CSP rules aggregated by the values of `csp.rules` from OSD YAML file and default values. If the aggregated CSP rules don't contain the CSP directive `frame-ancestors` which specifies valid parents that may embed OSD page, then the plugin will append `frame-ancestors 'self'` to prevent Clickjacking. + +--- + +## Configuration + +The plugin can be enabled by adding this line in OSD YML. + +``` +csp_handler.enabled: true + +``` + +Since it has a required dependency `applicationConfig`, make sure that the dependency is also enabled. + +``` +application_config.enabled: true +``` + +For OSD users who want to make changes to allow a new site to embed OSD pages, they can update CSP rules through CURL. (See the README of `applicationConfig` for more details about the APIs.) **Please note that use backslash as string wrapper for single quotes inside the `data-raw` parameter. E.g use `'\''` to represent `'`** + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"script-src '\''unsafe-eval'\'' '\''self'\''; worker-src blob: '\''self'\''; style-src '\''unsafe-inline'\'' '\''self'\''; frame-ancestors '\''self'\'' {new site}"}' + +``` + +Below is the CURL command to delete CSP rules. + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' +``` + +Below is the CURL command to get the CSP rules. + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' + +``` + +--- +## 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/csp_handler/common/index.ts b/src/plugins/csp_handler/common/index.ts new file mode 100644 index 00000000000..23a8ca4bd73 --- /dev/null +++ b/src/plugins/csp_handler/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'cspHandler'; +export const PLUGIN_NAME = 'CspHandler'; diff --git a/src/plugins/csp_handler/config.ts b/src/plugins/csp_handler/config.ts new file mode 100644 index 00000000000..914dcf8b279 --- /dev/null +++ b/src/plugins/csp_handler/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 CspHandlerConfigSchema = TypeOf; diff --git a/src/plugins/csp_handler/opensearch_dashboards.json b/src/plugins/csp_handler/opensearch_dashboards.json new file mode 100644 index 00000000000..8cc8f8e1f65 --- /dev/null +++ b/src/plugins/csp_handler/opensearch_dashboards.json @@ -0,0 +1,11 @@ +{ + "id": "cspHandler", + "version": "opensearchDashboards", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": false, + "requiredPlugins": [ + "applicationConfig" + ], + "optionalPlugins": [] +} \ No newline at end of file diff --git a/src/plugins/csp_handler/server/csp_handlers.test.ts b/src/plugins/csp_handler/server/csp_handlers.test.ts new file mode 100644 index 00000000000..d6c2f8a16d4 --- /dev/null +++ b/src/plugins/csp_handler/server/csp_handlers.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { createCspRulesPreResponseHandler } from './csp_handlers'; +import { MockedLogger, loggerMock } from '@osd/logging/target/mocks'; + +const ERROR_MESSAGE = 'Service unavailable'; + +describe('CSP handlers', () => { + let toolkit: ReturnType; + let logger: MockedLogger; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + logger = loggerMock.create(); + }); + + it('adds the CSP headers provided by the client', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromIndex = "frame-ancestors 'self'"; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(cspRulesFromIndex), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'content-security-policy': cspRulesFromIndex, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when the client returns empty and CSP from YML already has frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const emptyCspRules = ''; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(emptyCspRules), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('add frame-ancestors CSP headers when the client returns empty and CSP from YML has no frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const emptyCspRules = ''; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(emptyCspRules), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when the configuration does not exist and CSP from YML already has frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('add frame-ancestors CSP headers when the configuration does not exist and CSP from YML has no frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { method: 'get', headers: { 'sec-fetch-dest': 'document' } }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({ + headers: { + 'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when request dest exists and shall skip', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn(), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const cssSecFetchDest = 'css'; + const request = { + method: 'get', + headers: { 'sec-fetch-dest': cssSecFetchDest }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + }); + + it('do not add CSP headers when request dest does not exist', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn(), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: {}, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + }); +}); diff --git a/src/plugins/csp_handler/server/csp_handlers.ts b/src/plugins/csp_handler/server/csp_handlers.ts new file mode 100644 index 00000000000..cc14da74aed --- /dev/null +++ b/src/plugins/csp_handler/server/csp_handlers.ts @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ConfigurationClient } from '../../application_config/server'; +import { + CoreSetup, + IScopedClusterClient, + Logger, + OnPreResponseHandler, + OnPreResponseInfo, + OnPreResponseToolkit, + OpenSearchDashboardsRequest, +} from '../../../core/server'; + +const CSP_RULES_CONFIG_KEY = 'csp.rules'; + +/** + * This function creates a pre-response handler to dynamically set the CSP rules. + * It give precedence to the rules from application config plugin over those from YML. + * In case no value from application config, it will ensure a default frame-ancestors is set. + * + * @param core Context passed to the plugins `setup` method + * @param cspHeader The CSP header from YML + * @param getConfigurationClient The function provided by application config plugin to retrieve configurations + * @param logger The logger + * @returns The pre-response handler + */ +export function createCspRulesPreResponseHandler( + core: CoreSetup, + cspHeader: string, + getConfigurationClient: (scopedClusterClient: IScopedClusterClient) => ConfigurationClient, + logger: Logger +): OnPreResponseHandler { + return async ( + request: OpenSearchDashboardsRequest, + response: OnPreResponseInfo, + toolkit: OnPreResponseToolkit + ) => { + try { + const shouldCheckDest = ['document', 'frame', 'iframe', 'embed', 'object']; + + const currentDest = request.headers['sec-fetch-dest']; + + if (!shouldCheckDest.includes(currentDest)) { + return toolkit.next({}); + } + + const [coreStart] = await core.getStartServices(); + + const client = getConfigurationClient(coreStart.opensearch.client.asScoped(request)); + + const cspRules = await client.getEntityConfig(CSP_RULES_CONFIG_KEY); + + if (!cspRules) { + return appendFrameAncestorsWhenMissing(cspHeader, toolkit); + } + + const additionalHeaders = { + 'content-security-policy': cspRules, + }; + + return toolkit.next({ headers: additionalHeaders }); + } catch (e) { + logger.error(`Failure happened in CSP rules pre response handler due to ${e}`); + return appendFrameAncestorsWhenMissing(cspHeader, toolkit); + } + }; +} + +/** + * Append frame-ancestors with default value 'self' when it is missing. + */ +function appendFrameAncestorsWhenMissing(cspHeader: string, toolkit: OnPreResponseToolkit) { + if (cspHeader.includes('frame-ancestors')) { + return toolkit.next({}); + } + + const additionalHeaders = { + 'content-security-policy': "frame-ancestors 'self'; " + cspHeader, + }; + + return toolkit.next({ headers: additionalHeaders }); +} diff --git a/src/plugins/csp_handler/server/index.ts b/src/plugins/csp_handler/server/index.ts new file mode 100644 index 00000000000..3cbe9b3b14f --- /dev/null +++ b/src/plugins/csp_handler/server/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { CspHandlerConfigSchema, configSchema } from '../config'; +import { CspHandlerPlugin } 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 CspHandlerPlugin(initializerContext); +} + +export { CspHandlerPluginSetup, CspHandlerPluginStart } from './types'; diff --git a/src/plugins/csp_handler/server/plugin.ts b/src/plugins/csp_handler/server/plugin.ts new file mode 100644 index 00000000000..9f409426245 --- /dev/null +++ b/src/plugins/csp_handler/server/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CoreSetup, + CoreStart, + Logger, + Plugin, + PluginInitializerContext, +} from '../../../core/server'; + +import { createCspRulesPreResponseHandler } from './csp_handlers'; +import { AppPluginSetupDependencies, CspHandlerPluginSetup, CspHandlerPluginStart } from './types'; + +export class CspHandlerPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { + core.http.registerOnPreResponse( + createCspRulesPreResponseHandler( + core, + core.http.csp.header, + applicationConfig.getConfigurationClient, + this.logger + ) + ); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/csp_handler/server/types.ts b/src/plugins/csp_handler/server/types.ts new file mode 100644 index 00000000000..730fec3f7c6 --- /dev/null +++ b/src/plugins/csp_handler/server/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationConfigPluginSetup } from '../../application_config/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CspHandlerPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CspHandlerPluginStart {} + +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +} From 8f0885dab0fa84c85cc8f05a64da98ad784089c9 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Sat, 9 Mar 2024 09:42:43 +0800 Subject: [PATCH 4/4] [Workspace] Consume workspace id in saved object client (#6014) * feat: consume current workspace in saved objects management and saved objects client Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: add unit test for each change Signed-off-by: SuZhou-Joe * fix: update snapshot of unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * revert: saved object management changes Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe * feat: address some comment Signed-off-by: SuZhou-Joe * feat: address comment Signed-off-by: SuZhou-Joe * feat: remove useless return Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * feat: update CHANGELOG Signed-off-by: SuZhou-Joe * feat: update CHANGELOG Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: update comment Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- CHANGELOG.md | 1 + .../saved_objects_client.test.ts | 332 ++++++++++++++++++ .../saved_objects/saved_objects_client.ts | 48 ++- .../saved_objects_service.mock.ts | 1 + .../get_sorted_objects_for_export.test.ts | 67 ++++ .../import/resolve_import_errors.test.ts | 4 +- .../import/resolve_import_errors.ts | 1 + .../dashboard_listing.test.tsx.snap | 5 + .../dashboard_top_nav.test.tsx.snap | 6 + src/plugins/workspace/public/plugin.test.ts | 17 + src/plugins/workspace/public/plugin.ts | 23 +- 11 files changed, 498 insertions(+), 7 deletions(-) create mode 100644 src/plugins/workspace/public/plugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b07ea1dde7..830f213036d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) - [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) - [Multiple Datasource] Add Vega support to MDS by specifying a data source name in the Vega spec ([#5975](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5975)) +- [Workspace] Consume workspace id in saved object client ([#6014](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6014)) ### 🐛 Bug Fixes diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index cc3405f246c..b2f6d4afbb7 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -329,6 +329,26 @@ describe('SavedObjectsClient', () => { `); }); + test('makes HTTP call with workspaces', () => { + savedObjectsClient.create('index-pattern', attributes, { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + test('rejects when HTTP call fails', async () => { http.fetch.mockRejectedValueOnce(new Error('Request failed')); await expect( @@ -386,6 +406,29 @@ describe('SavedObjectsClient', () => { ] `); }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.bulkCreate([doc], { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": undefined, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); }); describe('#bulk_update', () => { @@ -510,5 +553,294 @@ describe('SavedObjectsClient', () => { ] `); }); + + test('makes HTTP call correctly with workspaces', () => { + const options = { + invalid: true, + workspaces: ['foo'], + }; + + // @ts-expect-error + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + }); +}); + +describe('SavedObjectsClientWithWorkspaceSet', () => { + const updatedAt = new Date().toISOString(); + const doc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' }, + version: 'foo', + updated_at: updatedAt, + }; + + const http = httpServiceMock.createStartContract(); + let savedObjectsClient: SavedObjectsClient; + + beforeEach(() => { + savedObjectsClient = new SavedObjectsClient(http); + savedObjectsClient.setCurrentWorkspace('foo'); + http.fetch.mockClear(); + }); + + describe('#create', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + + beforeEach(() => { + http.fetch.mockResolvedValue({ id: 'serverId', type: 'server-type', attributes }); + }); + + test('makes HTTP call with ID', () => { + savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern/myId", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + + test('makes HTTP call without ID', () => { + savedObjectsClient.create('index-pattern', attributes); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.create('index-pattern', attributes, { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + }); + + describe('#bulk_create', () => { + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [doc] }); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkCreate([doc]); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": false, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with overwrite query paramater', async () => { + await savedObjectsClient.bulkCreate([doc], { overwrite: true }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": true, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.bulkCreate([doc], { + workspaces: ['bar'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": undefined, + "workspaces": Array [ + "bar", + ], + }, + }, + ], + ] + `); + }); + }); + + describe('#bulk_update', () => { + const bulkUpdateDoc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' }, + version: 'foo', + }; + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [bulkUpdateDoc] }); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkUpdate([bulkUpdateDoc]); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_update", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\"}]", + "method": "PUT", + "query": undefined, + }, + ], + ] + `); + }); + }); + + describe('#find', () => { + const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; + + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [object], page: 0, per_page: 1, total: 1 }); + }); + + test('makes HTTP call correctly mapping options into snake case query parameters', () => { + const options = { + defaultSearchOperator: 'OR' as const, + fields: ['title'], + hasReference: { id: '1', type: 'reference' }, + page: 10, + perPage: 100, + search: 'what is the meaning of life?|life', + searchFields: ['title^5', 'body'], + sortField: 'sort_field', + type: 'index-pattern', + }; + + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "default_search_operator": "OR", + "fields": Array [ + "title", + ], + "has_reference": "{\\"id\\":\\"1\\",\\"type\\":\\"reference\\"}", + "page": 10, + "per_page": 100, + "search": "what is the meaning of life?|life", + "search_fields": Array [ + "title^5", + "body", + ], + "sort_field": "sort_field", + "type": "index-pattern", + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call correctly with workspaces', () => { + const options = { + invalid: true, + workspaces: ['bar'], + }; + + // @ts-expect-error + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "workspaces": Array [ + "bar", + ], + }, + }, + ], + ] + `); + }); }); }); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index d6b6b6b6d89..44e8be470c3 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -61,6 +61,7 @@ export interface SavedObjectsCreateOptions { /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; references?: SavedObjectReference[]; + workspaces?: string[]; } /** @@ -78,6 +79,7 @@ export interface SavedObjectsBulkCreateObject extends SavedObjectsC export interface SavedObjectsBulkCreateOptions { /** If a document with the given `id` already exists, overwrite it's contents (default=false). */ overwrite?: boolean; + workspaces?: string[]; } /** @public */ @@ -183,6 +185,35 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { export class SavedObjectsClient { private http: HttpSetup; private batchQueue: BatchQueueEntry[]; + /** + * The currentWorkspaceId may be undefined when workspace plugin is not enabled. + */ + private currentWorkspaceId: string | undefined; + + /** + * Check if workspaces field present in given options, if so, overwrite the current workspace id. + * @param options + * @returns + */ + private formatWorkspacesParams(options: { + workspaces?: SavedObjectsCreateOptions['workspaces']; + }): { workspaces: string[] } | {} { + const currentWorkspaceId = this.currentWorkspaceId; + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = [currentWorkspaceId]; + } + + if (finalWorkspaces) { + return { + workspaces: finalWorkspaces, + }; + } + + return {}; + } /** * Throttled processing of get requests into bulk requests at 100ms interval @@ -227,6 +258,10 @@ export class SavedObjectsClient { this.batchQueue = []; } + public setCurrentWorkspace(workspaceId: string) { + this.currentWorkspaceId = workspaceId; + } + /** * Persists an object * @@ -256,6 +291,7 @@ export class SavedObjectsClient { attributes, migrationVersion: options.migrationVersion, references: options.references, + ...this.formatWorkspacesParams(options), }), }); @@ -275,11 +311,14 @@ export class SavedObjectsClient { options: SavedObjectsBulkCreateOptions = { overwrite: false } ) => { const path = this.getPath(['_bulk_create']); - const query = { overwrite: options.overwrite }; + const query: HttpFetchOptions['query'] = { overwrite: options.overwrite }; const request: ReturnType = this.savedObjectsFetch(path, { method: 'POST', - query, + query: { + ...query, + ...this.formatWorkspacesParams(options), + }, body: JSON.stringify(objects), }); return request.then((resp) => { @@ -348,7 +387,10 @@ export class SavedObjectsClient { workspaces: 'workspaces', }; - const renamedQuery = renameKeys(renameMap, options); + const renamedQuery = renameKeys(renameMap, { + ...options, + ...this.formatWorkspacesParams(options), + }); const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Partial< Record >; diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 47bd146058f..00ca4407295 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -41,6 +41,7 @@ const createStartContractMock = () => { find: jest.fn(), get: jest.fn(), update: jest.fn(), + setCurrentWorkspace: jest.fn(), }, }; return mock; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index cf7e1d8246a..da477604c02 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -812,6 +812,73 @@ describe('getSortedObjectsForExport()', () => { `); }); + test('exports selected objects when passed workspaces', async () => { + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + references: [ + { + id: '1', + name: 'name', + type: 'index-pattern', + }, + ], + }, + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + ], + }); + await exportSavedObjectsToStream({ + exportSizeLimit: 10000, + savedObjectsClient, + objects: [ + { + type: 'index-pattern', + id: '1', + }, + { + type: 'search', + id: '2', + }, + ], + workspaces: ['foo'], + }); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + id: 1, + type: index-pattern, + }, + Object { + id: 2, + type: search, + }, + ], + Object { + namespace: undefined, + }, + ], + ], + "results": Array [ + Object { + type: return, + value: Promise {}, + }, + ], + } + `); + }); + test('export selected objects throws error when exceeding exportSizeLimit', async () => { const exportOpts = { exportSizeLimit: 1, diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index ef22155f046..35ca022df27 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -242,7 +242,8 @@ describe('#importSavedObjectsFromStream', () => { test('checks conflicts', async () => { const createNewCopies = (Symbol() as unknown) as boolean; const retries = [createRetry()]; - const options = setupOptions(retries, createNewCopies); + const workspaces = ['foo']; + const options = { ...setupOptions(retries, createNewCopies), workspaces }; const collectedObjects = [createObject()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], @@ -257,6 +258,7 @@ describe('#importSavedObjectsFromStream', () => { namespace, retries, createNewCopies, + workspaces, }; expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); }); 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 49b7c67b5ab..33f62b98eeb 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -131,6 +131,7 @@ export async function resolveSavedObjectsImportErrors({ retries, createNewCopies, dataSourceId, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index c9ffe147e5f..dfd9d3d3e37 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -932,6 +932,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2064,6 +2065,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3257,6 +3259,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4450,6 +4453,7 @@ exports[`dashboard listing renders table rows 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5643,6 +5647,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 1954051c947..54b40858b4f 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -810,6 +810,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -1767,6 +1768,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2724,6 +2726,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3681,6 +3684,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4638,6 +4642,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5595,6 +5600,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts new file mode 100644 index 00000000000..e1a45ee115a --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/public/mocks'; +import { WorkspacePlugin } from './plugin'; + +describe('Workspace plugin', () => { + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + workspacePlugin.start(coreStart); + coreStart.workspaces.currentWorkspaceId$.next('foo'); + expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + }); +}); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 18e84e3a6f3..3840066fcee 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,16 +3,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Plugin } from '../../../core/public'; +import type { Subscription } from 'rxjs'; +import { Plugin, CoreStart } from '../../../core/public'; export class WorkspacePlugin implements Plugin<{}, {}, {}> { + private coreStart?: CoreStart; + private currentWorkspaceSubscription?: Subscription; + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } + } public async setup() { return {}; } - public start() { + public start(core: CoreStart) { + this.coreStart = core; + + this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); return {}; } - public stop() {} + public stop() { + this.currentWorkspaceSubscription?.unsubscribe(); + } }