diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfoundesunavailableerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfoundesunavailableerror.md new file mode 100644 index 0000000000000..e05f9466aa9ee --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfoundesunavailableerror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createGenericNotFoundEsUnavailableError](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfoundesunavailableerror.md) + +## SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() method + +Signature: + +```typescript +static createGenericNotFoundEsUnavailableError(type: string, id: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 2dc78f2df3a83..67056c8a3cb50 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -18,6 +18,7 @@ export declare class SavedObjectsErrorHelpers | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | | [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | +| [createGenericNotFoundEsUnavailableError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfoundesunavailableerror.md) | static | | | [createIndexAliasNotFoundError(alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index a7fbce7180223..848d9c204bfbf 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -141,9 +141,10 @@ export type MockedTransportRequestPromise = TransportRequestPromise & { const createSuccessTransportRequestPromise = ( body: T, - { statusCode = 200 }: { statusCode?: number } = {} + { statusCode = 200 }: { statusCode?: number } = {}, + headers?: Record ): MockedTransportRequestPromise> => { - const response = createApiResponse({ body, statusCode }); + const response = createApiResponse({ body, statusCode, headers }); const promise = Promise.resolve(response); (promise as MockedTransportRequestPromise>).abort = jest.fn(); diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index d97e3331c7cf5..8bcc841669fc9 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -37,3 +37,4 @@ export type { GetResponse, DeleteDocumentResponse, } from './client'; +export { isSupportedEsServer } from './supported_server_response_check'; diff --git a/src/core/server/elasticsearch/supported_server_response_check.ts b/src/core/server/elasticsearch/supported_server_response_check.ts new file mode 100644 index 0000000000000..6fe812bc58518 --- /dev/null +++ b/src/core/server/elasticsearch/supported_server_response_check.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export const PRODUCT_RESPONSE_HEADER = 'x-elastic-product'; +/** + * Response headers check to determine if the response is from Elasticsearch + * @param headers Response headers + * @returns boolean + */ +// This check belongs to the elasticsearch service as a dedicated helper method. +export const isSupportedEsServer = (headers: Record | null) => { + return !!headers && headers[PRODUCT_RESPONSE_HEADER] === 'Elasticsearch'; +}; diff --git a/src/core/server/saved_objects/service/lib/errors.test.ts b/src/core/server/saved_objects/service/lib/errors.test.ts index a366dce626ec2..3bea693429254 100644 --- a/src/core/server/saved_objects/service/lib/errors.test.ts +++ b/src/core/server/saved_objects/service/lib/errors.test.ts @@ -439,4 +439,45 @@ describe('savedObjectsClient/errorTypes', () => { }); }); }); + + describe('NotFoundEsUnavailableError', () => { + it('makes an error identifiable as an EsUnavailable error', () => { + const error = SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError('foo', 'bar'); + expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); + }); + + it('returns a boom error', () => { + const error = SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError('foo', 'bar'); + expect(error).toHaveProperty('isBoom', true); + }); + + it('decorates the error message with the saved object that was not found', () => { + const error = SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError('foo', 'bar'); + expect(error.output.payload).toHaveProperty( + 'message', + 'x-elastic-product not present or not recognized: Saved object [foo/bar] not found' + ); + }); + + describe('error.output', () => { + it('specifies the saved object that was not found', () => { + const error = SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError( + 'foo', + 'bar' + ); + expect(error.output.payload).toHaveProperty( + 'message', + 'x-elastic-product not present or not recognized: Saved object [foo/bar] not found' + ); + }); + + it('sets statusCode to 503', () => { + const error = SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError( + 'foo', + 'bar' + ); + expect(error.output).toHaveProperty('statusCode', 503); + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index 581145c7c09d1..c1e1e9589b9ae 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -202,4 +202,12 @@ export class SavedObjectsErrorHelpers { public static isGeneralError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_GENERAL_ERROR; } + + public static createGenericNotFoundEsUnavailableError(type: string, id: string) { + const notFoundError = this.createGenericNotFoundError(type, id); + return this.decorateEsUnavailableError( + new Error(`${notFoundError.message}`), + `x-elastic-product not present or not recognized` + ); + } } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 78af9f0753374..c025adce29808 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -44,6 +44,8 @@ const createGenericNotFoundError = (...args) => SavedObjectsErrorHelpers.createGenericNotFoundError(...args).output.payload; const createUnsupportedTypeError = (...args) => SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; +const createGenericNotFoundEsUnavailableError = (...args) => + SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(...args).output.payload; describe('SavedObjectsRepository', () => { let client; @@ -2202,6 +2204,11 @@ describe('SavedObjectsRepository', () => { createGenericNotFoundError(type, id) ); }; + const expectNotFoundEsUnavailableError = async (type, id) => { + await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError(type, id) + ); + }; it(`throws when options.namespace is '*'`, async () => { await expect( @@ -2221,7 +2228,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + undefined, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -2229,12 +2240,30 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + {}, + { statusCode: 404 }, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); + it(`throws when ES is unable to find the document during get with missing Elasticsearch header`, async () => { + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); + await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); + }); + + it(`throws when ES is unable to find the index during get with missing Elasticsearch header`, async () => { + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); + }); + it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( @@ -2278,7 +2307,7 @@ describe('SavedObjectsRepository', () => { client.delete.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) ); - await expectNotFoundError(type, id); + await expectNotFoundEsUnavailableError(type, id); expect(client.delete).toHaveBeenCalledTimes(1); }); @@ -2288,7 +2317,7 @@ describe('SavedObjectsRepository', () => { error: { type: 'index_not_found_exception' }, }) ); - await expectNotFoundError(type, id); + await expectNotFoundEsUnavailableError(type, id); expect(client.delete).toHaveBeenCalledTimes(1); }); @@ -3170,7 +3199,11 @@ describe('SavedObjectsRepository', () => { createGenericNotFoundError(type, id) ); }; - + const expectNotFoundEsUnavailableError = async (type, id) => { + await expect(savedObjectsRepository.get(type, id)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError(type, id) + ); + }; it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) @@ -3189,7 +3222,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + undefined, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(type, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -3197,7 +3234,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + {}, + { statusCode: 404 }, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(type, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -3213,6 +3254,15 @@ describe('SavedObjectsRepository', () => { }); expect(client.get).toHaveBeenCalledTimes(1); }); + + it(`throws when ES does not return the correct header when finding the document during get`, async () => { + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); + await expectNotFoundEsUnavailableError(type, id); + + expect(client.get).toHaveBeenCalledTimes(1); + }); }); describe('returns', () => { @@ -3314,9 +3364,12 @@ describe('SavedObjectsRepository', () => { it('because alias is not used and actual object is not found', async () => { const options = { namespace: undefined }; - const response = { found: false }; client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + undefined, + { 'x-elastic-product': 'Elasticsearch' } + ) // for actual target ); await expectNotFoundError(type, id, options); @@ -3854,26 +3907,34 @@ describe('SavedObjectsRepository', () => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...mockGetResponse }, + { statusCode: 200 }, + { 'x-elastic-product': 'Elasticsearch' } + ) ); } client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - // don't need the rest of the source for test purposes, just the namespace and namespaces attributes - get: { - _source: { - namespaces: [options?.namespace ?? 'default'], - namespace: options?.namespace, + elasticsearchClientMock.createSuccessTransportRequestPromise( + { + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { + namespaces: [options?.namespace ?? 'default'], + namespace: options?.namespace, - // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the - // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. - ...(includeOriginId && { originId }), + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }, }, }, - }) + { statusCode: 200 }, + { 'x-elastic-product': 'Elasticsearch' } + ) ); const result = await savedObjectsRepository.update(type, id, attributes, options); expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); @@ -4059,6 +4120,11 @@ describe('SavedObjectsRepository', () => { createGenericNotFoundError(type, id) ); }; + const expectNotFoundEsUnavailableError = async (type, id) => { + await expect(savedObjectsRepository.update(type, id)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError(type, id) + ); + }; it(`throws when options.namespace is '*'`, async () => { await expect( @@ -4078,7 +4144,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + undefined, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -4086,12 +4156,32 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + {}, + { statusCode: 404 }, + { 'x-elastic-product': 'Elasticsearch' } + ) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); + it(`throws when ES is unable to find the document during get with missing Elasticsearch header`, async () => { + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); + await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it(`throws when ES is unable to find the index during get with missing Elasticsearch`, async () => { + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); + expect(client.get).toHaveBeenCalledTimes(1); + }); + it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 6899f8613b07f..7ac4fe87bfc19 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -14,6 +14,7 @@ import { REPOSITORY_RESOLVE_OUTCOME_STATS, } from '../../../core_usage_data'; import type { ElasticsearchClient } from '../../../elasticsearch/'; +import { isSupportedEsServer } from '../../../elasticsearch'; import type { Logger } from '../../../logging'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { @@ -648,7 +649,7 @@ export class SavedObjectsRepository { } } - const { body, statusCode } = await this.client.delete( + const { body, statusCode, headers } = await this.client.delete( { id: rawId, index: this.getIndexForType(type), @@ -665,9 +666,15 @@ export class SavedObjectsRepository { const deleteDocNotFound = body.result === 'not_found'; const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; + const esServerSupported = isSupportedEsServer(headers); if (deleteDocNotFound || deleteIndexNotFound) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + if (esServerSupported) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else { + // throw if we can't verify the response is from Elasticsearch + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } } throw new Error( @@ -1009,19 +1016,19 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const namespace = normalizeNamespace(options.namespace); - - const { body, statusCode } = await this.client.get( + const { body, statusCode, headers } = await this.client.get( { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), }, { ignore: [404] } ); - const indexNotFound = statusCode === 404; - + // check if we have the elasticsearch header when index is not found and if we do, ensure it is Elasticsearch + if (!isFoundGetResponse(body) && !isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } if ( !isFoundGetResponse(body) || indexNotFound || @@ -1030,7 +1037,6 @@ export class SavedObjectsRepository { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return getSavedObjectFromSource(this._registry, type, id, body); } @@ -1248,7 +1254,19 @@ export class SavedObjectsRepository { _source_includes: ['namespace', 'namespaces', 'originId'], require_alias: true, }) + .then((res) => { + const indexNotFound = res.statusCode === 404; + const esServerSupported = isSupportedEsServer(res.headers); + // check if we have the elasticsearch header when index is not found and if we do, ensure it is Elasticsearch + if (indexNotFound && !esServerSupported) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } + return res; + }) .catch((err) => { + if (SavedObjectsErrorHelpers.isEsUnavailableError(err)) { + throw err; + } if (SavedObjectsErrorHelpers.isNotFoundError(err)) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -2070,7 +2088,7 @@ export class SavedObjectsRepository { * @param id The ID of the saved object. * @param namespace The target namespace. * @returns Raw document from Elasticsearch. - * @throws Will throw an error if the saved object is not found, or if it doesn't include the target namespace. + * @throws Will throw an error if the saved object is not found, if it doesn't include the target namespace or if the response is not identifiable as an Elasticsearch response. */ private async preflightCheckIncludesNamespace(type: string, id: string, namespace?: string) { if (!this._registry.isMultiNamespace(type)) { @@ -2078,7 +2096,7 @@ export class SavedObjectsRepository { } const rawId = this._serializer.generateRawId(undefined, type, id); - const { body, statusCode } = await this.client.get( + const { body, statusCode, headers } = await this.client.get( { id: rawId, index: this.getIndexForType(type), @@ -2087,6 +2105,14 @@ export class SavedObjectsRepository { ); const indexFound = statusCode !== 404; + + // check if we have the elasticsearch header when index is not found and if we do, ensure it is Elasticsearch + const esServerSupported = isSupportedEsServer(headers); + + if (!isFoundGetResponse(body) && !esServerSupported) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } + if ( !indexFound || !isFoundGetResponse(body) || diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 7f2ce38a5bdd4..48b0c1488e81e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2523,6 +2523,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createGenericNotFoundEsUnavailableError(type: string, id: string): DecoratedError; + // (undocumented) static createIndexAliasNotFoundError(alias: string): DecoratedError; // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError;