diff --git a/CHANGELOG.md b/CHANGELOG.md index 2235557a18e9..979d6e02f204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) - [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) - [Multiple Datasource] Refactoring create and edit form to use authentication registry ([#6002](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6002)) +- [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)) diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index 56b5f5caf2e8..12e01692b2e6 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -61,9 +61,15 @@ export class DataSourcePlugin implements Plugin { + const dataSourcePluginStart = selfStart as DataSourcePluginStart; + return dataSourcePluginStart.getAuthenticationMethodRegistery(); + }); + const dataSourceSavedObjectsClientWrapper = new DataSourceSavedObjectsClientWrapper( cryptographyServiceSetup, this.logger.get('data-source-saved-objects-client-wrapper-factory'), + authRegistryPromise, config.endpointDeniedIPs ); @@ -101,11 +107,6 @@ export class DataSourcePlugin implements Plugin { - const dataSourcePluginStart = selfStart as DataSourcePluginStart; - return dataSourcePluginStart.getAuthenticationMethodRegistery(); - }); - const customApiSchemaRegistryPromise = core.getStartServices().then(([, , selfStart]) => { const dataSourcePluginStart = selfStart as DataSourcePluginStart; return dataSourcePluginStart.getCustomApiSchemaRegistry(); diff --git a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts new file mode 100644 index 000000000000..d6d06374a67c --- /dev/null +++ b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts @@ -0,0 +1,541 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import uuid from 'uuid'; +import { + httpServerMock, + savedObjectsClientMock, + coreMock, + loggingSystemMock, +} from '../../../../core/server/mocks'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; +import { AuthType } from '../../common/data_sources'; +import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; +import { DataSourceSavedObjectsClientWrapper } from './data_source_saved_objects_client_wrapper'; +import { SavedObject } from 'opensearch-dashboards/public'; + +describe('DataSourceSavedObjectsClientWrapper', () => { + const customAuthName = 'role_based_auth'; + const customAuthMethod = { + name: customAuthName, + authType: AuthType.SigV4, + credentialProvider: jest.fn(), + }; + jest.mock('../auth_registry'); + const { AuthenticationMethodRegistery: authenticationMethodRegistery } = jest.requireActual( + '../auth_registry' + ); + const authRegistry = new authenticationMethodRegistery(); + authRegistry.registerAuthenticationMethod(customAuthMethod); + + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const cryptographyMock = cryptographyServiceSetupMock.create(); + const logger = loggingSystemMock.createLogger(); + const authRegistryPromise = Promise.resolve(authRegistry); + const wrapperInstance = new DataSourceSavedObjectsClientWrapper( + cryptographyMock, + logger, + authRegistryPromise + ); + const mockedClient = savedObjectsClientMock.create(); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: httpServerMock.createOpenSearchDashboardsRequest(), + }); + + const getSavedObject = (savedObject: Partial) => { + const payload: SavedObject = { + references: [], + id: '', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes: {}, + ...savedObject, + }; + + return payload; + }; + + const attributes = (attribute?: any) => { + return { + title: 'create-test-ds123', + description: 'jest testing', + endpoint: 'https://test.com', + ...attribute, + }; + }; + + describe('createWithCredentialsEncryption', () => { + beforeEach(() => { + mockedClient.create.mockClear(); + }); + it('should create data source when auth type is NO_AUTH', async () => { + const mockDataSourceAttributesWithNoAuth = attributes({ + auth: { + type: AuthType.NoAuth, + }, + }); + await wrapperClient.create( + DATA_SOURCE_SAVED_OBJECT_TYPE, + mockDataSourceAttributesWithNoAuth, + {} + ); + expect(mockedClient.create).toBeCalledWith( + expect.stringMatching(DATA_SOURCE_SAVED_OBJECT_TYPE), + expect.objectContaining(mockDataSourceAttributesWithNoAuth), + expect.anything() + ); + }); + + it('should create data source when auth type is UsernamePasswordType', async () => { + const password = 'test123'; + const encryptedPassword = 'XXXXYYY'; + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'test123', + password, + }, + }, + }); + cryptographyMock.encryptAndEncode.mockResolvedValueOnce(Promise.resolve(encryptedPassword)); + await wrapperClient.create( + DATA_SOURCE_SAVED_OBJECT_TYPE, + mockDataSourceAttributesWithAuth, + {} + ); + expect(mockedClient.create).toBeCalledWith( + expect.stringMatching(DATA_SOURCE_SAVED_OBJECT_TYPE), + expect.objectContaining({ + ...mockDataSourceAttributesWithAuth, + auth: { + ...mockDataSourceAttributesWithAuth.auth, + credentials: { + username: 'test123', + password: encryptedPassword, + }, + }, + }), + expect.anything() + ); + }); + + it('should create data source when auth type is SigV4', async () => { + const accessKey = uuid(); + const secretKey = uuid(); + const region = 'us-east-1'; + const service = 'es'; + const encryptedAccessKey = `encrypted_${accessKey}`; + const encryptedSecretKey = `encrypted_${secretKey}`; + const mockDataSourceAttributesWithSigV4 = attributes({ + auth: { + type: AuthType.SigV4, + credentials: { + accessKey, + secretKey, + region, + service, + }, + }, + }); + cryptographyMock.encryptAndEncode.mockResolvedValueOnce(Promise.resolve(encryptedAccessKey)); + cryptographyMock.encryptAndEncode.mockResolvedValueOnce(Promise.resolve(encryptedSecretKey)); + await wrapperClient.create( + DATA_SOURCE_SAVED_OBJECT_TYPE, + mockDataSourceAttributesWithSigV4, + {} + ); + expect(mockedClient.create).toBeCalledWith( + expect.stringMatching(DATA_SOURCE_SAVED_OBJECT_TYPE), + expect.objectContaining({ + ...mockDataSourceAttributesWithSigV4, + auth: { + ...mockDataSourceAttributesWithSigV4.auth, + credentials: { + ...mockDataSourceAttributesWithSigV4.auth.credentials, + accessKey: encryptedAccessKey, + secretKey: encryptedSecretKey, + }, + }, + }), + expect.anything() + ); + }); + + it('should create data source when auth type is present in auth registry', async () => { + const mockDataSourceAttributes = attributes({ + auth: { + type: customAuthName, + }, + }); + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributes, {}); + expect(mockedClient.create).toBeCalledWith( + expect.stringMatching(DATA_SOURCE_SAVED_OBJECT_TYPE), + expect.objectContaining(mockDataSourceAttributes), + expect.anything() + ); + }); + + it('should throw error when auth type is neigther supported by default nor present in auth registry', async () => { + const type = 'not_in_registry'; + const mockDataSourceAttributes = attributes({ + auth: { + type, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributes, {}) + ).rejects.toThrowError(`Invalid auth type: 'not_in_registry': Bad Request`); + }); + + describe('createWithCredentialsEncryption: Error handling', () => { + it('should throw error when title is empty', async () => { + const mockDataSourceAttributes = attributes({ + title: '', + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributes, {}) + ).rejects.toThrowError(`"title" attribute must be a non-empty string`); + }); + + it('should throw error when endpoint is not valid', async () => { + const mockDataSourceAttributes = attributes({ + endpoint: 'asasasasas', + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributes, {}) + ).rejects.toThrowError(`"endpoint" attribute is not valid or allowed`); + }); + + it('should throw error when auth is not present', async () => { + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes(), {}) + ).rejects.toThrowError(`"auth" attribute is required`); + }); + + it('should throw error when type field is not present in auth', async () => { + const mockDataSourceAttributes = attributes({ + auth: {}, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributes, {}) + ).rejects.toThrowError(`"auth.type" attribute is required`); + }); + + it('should throw error when credentials are not present in auth when auth type is UsernamePasswordType', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.UsernamePasswordType, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials" attribute is required`); + }); + + it('should throw error when username is not present in auth when auth type is UsernamePasswordType', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.username" attribute is required`); + }); + + it('should throw error when password is not present in auth when auth type is UsernamePasswordType', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'test', + }, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.password" attribute is required`); + }); + + it('should throw error when credentials are not present in auth when auth type is SigV4', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.SigV4, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials" attribute is required`); + }); + + it('should throw error when accessKey is not present in auth when auth type is SigV4', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.accessKey" attribute is required`); + }); + + it('should throw error when secretKey is not present in auth when auth type is SigV4', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'test', + }, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.secretKey" attribute is required`); + }); + + it('should throw error when region is not present in auth when auth type is SigV4', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'test', + secretKey: 'test', + }, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.region" attribute is required`); + }); + + it('should throw error when service is not present in auth when auth type is SigV4', async () => { + const mockDataSourceAttributesWithAuth = attributes({ + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'test', + secretKey: 'test', + region: 'us-east-1', + }, + }, + }); + await expect( + wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, mockDataSourceAttributesWithAuth, {}) + ).rejects.toThrowError(`"auth.credentials.service" attribute is required`); + }); + }); + }); + + describe('bulkCreateWithCredentialsEncryption', () => { + beforeEach(() => { + mockedClient.bulkCreate.mockClear(); + }); + + it('should create data sources when auth type is UsernamePasswordType', async () => { + const password = 'test123'; + const encryptedPassword = 'XXXXYYY'; + const mockDataSourceAttributesWithAuth = attributes({ + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'test123', + password, + }, + }, + }); + cryptographyMock.encryptAndEncode.mockResolvedValueOnce(Promise.resolve(encryptedPassword)); + await wrapperClient.bulkCreate( + [ + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributesWithAuth, + }), + ], + {} + ); + expect(mockedClient.bulkCreate).toBeCalledWith( + [ + { + attributes: { + ...mockDataSourceAttributesWithAuth, + auth: { + ...mockDataSourceAttributesWithAuth.auth, + credentials: { + username: 'test123', + password: encryptedPassword, + }, + }, + }, + id: 'test1', + references: [], + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }, + ], + {} + ); + }); + + it('should create data sources when auth type is present in auth registry', async () => { + const mockDataSourceAttributes = attributes({ + auth: { + type: customAuthName, + }, + }); + await wrapperClient.bulkCreate( + [ + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributes, + }), + ], + {} + ); + expect(mockedClient.bulkCreate).toBeCalledWith( + [ + { + attributes: mockDataSourceAttributes, + id: 'test1', + references: [], + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }, + ], + {} + ); + }); + }); + + describe('updateWithCredentialsEncryption', () => { + beforeEach(() => { + mockedClient.update.mockClear(); + }); + + it('should throw error when pass endpoint to update', async () => { + const id = 'test1'; + await expect( + wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, id, attributes()) + ).rejects.toThrowError(`Updating a dataSource endpoint is not supported`); + }); + + it('should update data source when auth type is present in auth registry', async () => { + const id = 'test1'; + const mockDataSourceAttributes = attributes({ + auth: { + type: customAuthName, + }, + }); + const { endpoint, ...newObject1 } = mockDataSourceAttributes; + mockedClient.get.mockResolvedValue( + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributes, + }) + ); + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, id, newObject1); + expect(mockedClient.update).toBeCalledWith( + expect.stringMatching(DATA_SOURCE_SAVED_OBJECT_TYPE), + expect.stringMatching(id), + expect.objectContaining(newObject1), + expect.anything() + ); + expect(mockedClient.update).not.toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ endpoint }), + expect.anything() + ); + }); + + it('should update throw error when auth type is not present in auth registry', async () => { + const id = 'test1'; + const mockDataSourceAttributes = attributes({ + auth: { + type: 'not_in_registry', + }, + }); + const { endpoint, ...newObject1 } = mockDataSourceAttributes; + mockedClient.get.mockResolvedValue( + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributes, + }) + ); + await expect( + wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, id, newObject1) + ).rejects.toThrowError(`Invalid auth type: 'not_in_registry': Bad Request`); + }); + }); + + describe('bulkUpdateWithCredentialsEncryption', () => { + beforeEach(() => { + mockedClient.bulkUpdate.mockClear(); + }); + + it('should update data sources when auth type is present in auth registry', async () => { + const mockDataSourceAttributes = attributes({ + auth: { + type: customAuthName, + }, + }); + const { endpoint, ...bulkUpdateObject } = mockDataSourceAttributes; + mockedClient.get.mockResolvedValue( + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributes, + }) + ); + await wrapperClient.bulkUpdate( + [ + { + id: 'test1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes: bulkUpdateObject, + }, + ], + {} + ); + expect(mockedClient.bulkUpdate).toBeCalledWith( + [ + { + attributes: bulkUpdateObject, + id: 'test1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }, + ], + {} + ); + }); + + it('should bulk update throw error when auth type is not present in auth registry', async () => { + const mockDataSourceAttributes = attributes({ + auth: { + type: 'not_in_registry', + }, + }); + const { endpoint, ...bulkUpdateObject } = mockDataSourceAttributes; + mockedClient.get.mockResolvedValue( + getSavedObject({ + id: 'test1', + attributes: mockDataSourceAttributes, + }) + ); + await expect( + wrapperClient.bulkUpdate( + [ + { + id: 'test1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes: bulkUpdateObject, + }, + ], + {} + ) + ).rejects.toThrowError(`Invalid auth type: 'not_in_registry': Bad Request`); + }); + }); +}); diff --git a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts index 12d60b8da51e..a35313553993 100644 --- a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts +++ b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts @@ -25,6 +25,7 @@ import { } from '../../common/data_sources'; import { EncryptionContext, CryptographyServiceSetup } from '../cryptography_service'; import { isValidURL } from '../util/endpoint_validator'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; /** * Describes the Credential Saved Objects Client Wrapper class, @@ -140,11 +141,12 @@ export class DataSourceSavedObjectsClientWrapper { constructor( private cryptography: CryptographyServiceSetup, private logger: Logger, + private authRegistryPromise: Promise, private endpointBlockedIps?: string[] ) {} private async validateAndEncryptAttributes(attributes: T) { - this.validateAttributes(attributes); + await this.validateAttributes(attributes); const { endpoint, auth } = attributes; @@ -170,6 +172,9 @@ export class DataSourceSavedObjectsClientWrapper { auth: await this.encryptSigV4Credential(auth, { endpoint }), }; default: + if (await this.isAuthTypeAvailableInRegistry(auth.type)) { + return attributes; + } throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${auth.type}'`); } } @@ -238,11 +243,14 @@ export class DataSourceSavedObjectsClientWrapper { return attributes; } default: + if (await this.isAuthTypeAvailableInRegistry(auth.type)) { + return attributes; + } throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid credentials type: '${type}'`); } } - private validateAttributes(attributes: T) { + private async validateAttributes(attributes: T) { const { title, endpoint, auth } = attributes; if (!title?.trim?.().length) { throw SavedObjectsErrorHelpers.createBadRequestError( @@ -260,10 +268,10 @@ export class DataSourceSavedObjectsClientWrapper { throw SavedObjectsErrorHelpers.createBadRequestError('"auth" attribute is required'); } - this.validateAuth(auth); + await this.validateAuth(auth); } - private validateAuth(auth: T) { + private async validateAuth(auth: T) { const { type, credentials } = auth; if (!type) { @@ -328,6 +336,9 @@ export class DataSourceSavedObjectsClientWrapper { } break; default: + if (await this.isAuthTypeAvailableInRegistry(type)) { + break; + } throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${type}'`); } } @@ -396,6 +407,9 @@ export class DataSourceSavedObjectsClientWrapper { encryptionContext = accessKeyEncryptionContext; break; default: + if (await this.isAuthTypeAvailableInRegistry(auth.type)) { + return attributes; + } throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${auth.type}'`); } @@ -476,4 +490,14 @@ export class DataSourceSavedObjectsClientWrapper { }, }; } + + private async getAuthenticationMethodFromRegistry(type: string) { + const authMethod = (await this.authRegistryPromise).getAuthenticationMethod(type); + return authMethod; + } + + private async isAuthTypeAvailableInRegistry(type: string): Promise { + const authMethod = await this.getAuthenticationMethodFromRegistry(type); + return authMethod !== undefined; + } }