From b6060544cc1808e2aca3a9611b542ae09b335313 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 18 Jan 2022 14:40:12 +0100 Subject: [PATCH] Add API to refresh authc headers and retry ES request when 401 is encountered (#120677) * initial POC * remove test code * update the header holding logic * add new API to plugin context * introduce the IAuthHeadersStorage interface * fix some types, mocks and tests * export types from server entrypoint * also export error type * more doc * update generated doc * Fix ES service tests * add tests for createInternalErrorHandler * fix type in cli_setup * generated doc * add tests for configureClient * add unit tests for custom transport class * fix handler propagation to initial clients * lint * address review comments --- ...n-core-server.elasticsearchservicesetup.md | 1 + ...ervicesetup.setunauthorizederrorhandler.md | 35 ++ .../core/server/kibana-plugin-core-server.md | 8 + ...na-plugin-core-server.unauthorizederror.md | 14 + ...in-core-server.unauthorizederrorhandler.md | 13 + ...nauthorizederrorhandlernothandledresult.md | 19 + ...orizederrorhandlernothandledresult.type.md | 11 + ...r.unauthorizederrorhandleroptions.error.md | 11 + ...-server.unauthorizederrorhandleroptions.md | 20 + ...unauthorizederrorhandleroptions.request.md | 11 + ...e-server.unauthorizederrorhandlerresult.md | 12 + ...rorhandlerresultretryparams.authheaders.md | 11 + ...authorizederrorhandlerresultretryparams.md | 19 + ...ver.unauthorizederrorhandlerretryresult.md | 20 + ...nauthorizederrorhandlerretryresult.type.md | 11 + ...-server.unauthorizederrorhandlertoolkit.md | 21 + ...uthorizederrorhandlertoolkit.nothandled.md | 13 + ...r.unauthorizederrorhandlertoolkit.retry.md | 13 + src/cli_setup/utils.ts | 8 +- .../client/cluster_client.test.mocks.ts | 10 + .../client/cluster_client.test.ts | 378 ++++++++------ .../elasticsearch/client/cluster_client.ts | 65 ++- .../client/configure_client.test.mocks.ts | 5 + .../client/configure_client.test.ts | 36 +- .../elasticsearch/client/configure_client.ts | 26 +- .../client/create_transport.test.mocks.ts | 32 ++ .../client/create_transport.test.ts | 469 ++++++++++++++++++ .../elasticsearch/client/create_transport.ts | 90 ++++ .../server/elasticsearch/client/errors.ts | 1 + src/core/server/elasticsearch/client/index.ts | 10 + .../client/retry_unauthorized.test.ts | 197 ++++++++ .../client/retry_unauthorized.ts | 137 +++++ .../elasticsearch_service.mock.ts | 24 +- .../elasticsearch_service.test.ts | 12 +- .../elasticsearch/elasticsearch_service.ts | 25 +- src/core/server/elasticsearch/index.ts | 9 + src/core/server/elasticsearch/types.ts | 24 + src/core/server/http/auth_headers_storage.ts | 14 +- src/core/server/http/http_server.ts | 6 +- src/core/server/http/http_service.mock.ts | 17 +- src/core/server/http/index.ts | 2 +- src/core/server/http/types.ts | 5 +- src/core/server/index.ts | 8 + src/core/server/plugins/plugin_context.ts | 1 + src/core/server/server.api.md | 47 +- 45 files changed, 1713 insertions(+), 208 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederror.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandler.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresult.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md create mode 100644 src/core/server/elasticsearch/client/create_transport.test.mocks.ts create mode 100644 src/core/server/elasticsearch/client/create_transport.test.ts create mode 100644 src/core/server/elasticsearch/client/create_transport.ts create mode 100644 src/core/server/elasticsearch/client/retry_unauthorized.test.ts create mode 100644 src/core/server/elasticsearch/client/retry_unauthorized.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md index d18620992075d..f66333427a224 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md @@ -16,4 +16,5 @@ export interface ElasticsearchServiceSetup | Property | Type | Description | | --- | --- | --- | | [legacy](./kibana-plugin-core-server.elasticsearchservicesetup.legacy.md) | { readonly config$: Observable<ElasticsearchConfig>; } | | +| [setUnauthorizedErrorHandler](./kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md) | (handler: UnauthorizedErrorHandler) => void | Register a handler that will be called when unauthorized (401) errors are returned from any API call to elasticsearch performed on behalf of a user via a [scoped cluster client](./kibana-plugin-core-server.iscopedclusterclient.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md new file mode 100644 index 0000000000000..ee403a800d934 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md @@ -0,0 +1,35 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) > [setUnauthorizedErrorHandler](./kibana-plugin-core-server.elasticsearchservicesetup.setunauthorizederrorhandler.md) + +## ElasticsearchServiceSetup.setUnauthorizedErrorHandler property + +Register a handler that will be called when unauthorized (401) errors are returned from any API call to elasticsearch performed on behalf of a user via a [scoped cluster client](./kibana-plugin-core-server.iscopedclusterclient.md). + +Signature: + +```typescript +setUnauthorizedErrorHandler: (handler: UnauthorizedErrorHandler) => void; +``` + +## Remarks + +The handler will only be invoked for scoped client bound to real [request](./kibana-plugin-core-server.kibanarequest.md) instances. + +## Example + + +```ts +const handler: UnauthorizedErrorHandler = ({ request, error }, toolkit) => { + const reauthenticationResult = await authenticator.reauthenticate(request, error); + if (reauthenticationResult.succeeded()) { + return toolkit.retry({ + authHeaders: reauthenticationResult.authHeaders, + }); + } + return toolkit.notHandled(); +} + +coreSetup.elasticsearch.setUnauthorizedErrorHandler(handler); +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 6b0f65861ad5b..3fdf7de0b49f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -230,6 +230,11 @@ The plugin integrates with the core system via lifecycle events: `setup` | [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | | +| [UnauthorizedErrorHandlerNotHandledResult](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md) | | +| [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) | | +| [UnauthorizedErrorHandlerResultRetryParams](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md) | | +| [UnauthorizedErrorHandlerRetryResult](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md) | | +| [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) | Toolkit passed to a [UnauthorizedErrorHandler](./kibana-plugin-core-server.unauthorizederrorhandler.md) used to generate responses from the handler | | [UserProvidedValues](./kibana-plugin-core-server.userprovidedvalues.md) | Describes the values explicitly set by user. | ## Variables @@ -329,4 +334,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | | [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) | UI element type to represent the settings. | +| [UnauthorizedError](./kibana-plugin-core-server.unauthorizederror.md) | | +| [UnauthorizedErrorHandler](./kibana-plugin-core-server.unauthorizederrorhandler.md) | A handler used to handle unauthorized error returned by elasticsearch | +| [UnauthorizedErrorHandlerResult](./kibana-plugin-core-server.unauthorizederrorhandlerresult.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederror.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederror.md new file mode 100644 index 0000000000000..a79d62ab7f3c7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederror.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedError](./kibana-plugin-core-server.unauthorizederror.md) + +## UnauthorizedError type + + +Signature: + +```typescript +export declare type UnauthorizedError = errors.ResponseError & { + statusCode: 401; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandler.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandler.md new file mode 100644 index 0000000000000..10c6253619217 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandler.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandler](./kibana-plugin-core-server.unauthorizederrorhandler.md) + +## UnauthorizedErrorHandler type + +A handler used to handle unauthorized error returned by elasticsearch + +Signature: + +```typescript +export declare type UnauthorizedErrorHandler = (options: UnauthorizedErrorHandlerOptions, toolkit: UnauthorizedErrorHandlerToolkit) => MaybePromise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md new file mode 100644 index 0000000000000..2300d53816e07 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerNotHandledResult](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md) + +## UnauthorizedErrorHandlerNotHandledResult interface + + +Signature: + +```typescript +export interface UnauthorizedErrorHandlerNotHandledResult +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md) | 'notHandled' | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md new file mode 100644 index 0000000000000..9a8fc62517bce --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerNotHandledResult](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md) > [type](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md) + +## UnauthorizedErrorHandlerNotHandledResult.type property + +Signature: + +```typescript +type: 'notHandled'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md new file mode 100644 index 0000000000000..b3b355dfdc97e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) > [error](./kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md) + +## UnauthorizedErrorHandlerOptions.error property + +Signature: + +```typescript +error: UnauthorizedError; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.md new file mode 100644 index 0000000000000..efaf5109568be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) + +## UnauthorizedErrorHandlerOptions interface + + +Signature: + +```typescript +export interface UnauthorizedErrorHandlerOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md) | UnauthorizedError | | +| [request](./kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md) | KibanaRequest | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md new file mode 100644 index 0000000000000..94a0970844615 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) > [request](./kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md) + +## UnauthorizedErrorHandlerOptions.request property + +Signature: + +```typescript +request: KibanaRequest; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresult.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresult.md new file mode 100644 index 0000000000000..fb5d8ad7d35c5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresult.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerResult](./kibana-plugin-core-server.unauthorizederrorhandlerresult.md) + +## UnauthorizedErrorHandlerResult type + + +Signature: + +```typescript +export declare type UnauthorizedErrorHandlerResult = UnauthorizedErrorHandlerRetryResult | UnauthorizedErrorHandlerNotHandledResult; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md new file mode 100644 index 0000000000000..f6a8cb3d5b613 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerResultRetryParams](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md) > [authHeaders](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md) + +## UnauthorizedErrorHandlerResultRetryParams.authHeaders property + +Signature: + +```typescript +authHeaders: AuthHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md new file mode 100644 index 0000000000000..d9c8e4c0733a7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerResultRetryParams](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md) + +## UnauthorizedErrorHandlerResultRetryParams interface + + +Signature: + +```typescript +export interface UnauthorizedErrorHandlerResultRetryParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [authHeaders](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md) | AuthHeaders | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md new file mode 100644 index 0000000000000..09d7f08229be3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerRetryResult](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md) + +## UnauthorizedErrorHandlerRetryResult interface + + +Signature: + +```typescript +export interface UnauthorizedErrorHandlerRetryResult extends UnauthorizedErrorHandlerResultRetryParams +``` +Extends: UnauthorizedErrorHandlerResultRetryParams + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md) | 'retry' | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md new file mode 100644 index 0000000000000..64d1e9270682b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerRetryResult](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md) > [type](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md) + +## UnauthorizedErrorHandlerRetryResult.type property + +Signature: + +```typescript +type: 'retry'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md new file mode 100644 index 0000000000000..f28dae053b351 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) + +## UnauthorizedErrorHandlerToolkit interface + +Toolkit passed to a [UnauthorizedErrorHandler](./kibana-plugin-core-server.unauthorizederrorhandler.md) used to generate responses from the handler + +Signature: + +```typescript +export interface UnauthorizedErrorHandlerToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [notHandled](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md) | () => UnauthorizedErrorHandlerNotHandledResult | The handler cannot handle the error, or was not able to authenticate. | +| [retry](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md) | (params: UnauthorizedErrorHandlerResultRetryParams) => UnauthorizedErrorHandlerRetryResult | The handler was able to authenticate. Will retry the failed request with new auth headers | + diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md new file mode 100644 index 0000000000000..76fc3dd48ff74 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) > [notHandled](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md) + +## UnauthorizedErrorHandlerToolkit.notHandled property + +The handler cannot handle the error, or was not able to authenticate. + +Signature: + +```typescript +notHandled: () => UnauthorizedErrorHandlerNotHandledResult; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md new file mode 100644 index 0000000000000..6a5e5c78414ac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) > [retry](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md) + +## UnauthorizedErrorHandlerToolkit.retry property + +The handler was able to authenticate. Will retry the failed request with new auth headers + +Signature: + +```typescript +retry: (params: UnauthorizedErrorHandlerResultRetryParams) => UnauthorizedErrorHandlerRetryResult; +``` diff --git a/src/cli_setup/utils.ts b/src/cli_setup/utils.ts index aa85abf978b3f..03eca6fe8e0ac 100644 --- a/src/cli_setup/utils.ts +++ b/src/cli_setup/utils.ts @@ -37,8 +37,8 @@ export const elasticsearch = new ElasticsearchService(logger, kibanaPackageJson. elasticsearch: { createClient: (type, config) => { const defaults = configSchema.validate({}); - return new ClusterClient( - merge( + return new ClusterClient({ + config: merge( defaults, { hosts: Array.isArray(defaults.hosts) ? defaults.hosts : [defaults.hosts], @@ -46,8 +46,8 @@ export const elasticsearch = new ElasticsearchService(logger, kibanaPackageJson. config ), logger, - type - ); + type, + }); }, }, }); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts b/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts index 4a99daa0ddd33..f4672c08ca17b 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts @@ -10,3 +10,13 @@ export const configureClientMock = jest.fn(); jest.doMock('./configure_client', () => ({ configureClient: configureClientMock, })); + +export const createTransportMock = jest.fn(); +jest.doMock('./create_transport', () => ({ + createTransport: createTransportMock, +})); + +export const createInternalErrorHandlerMock = jest.fn(); +jest.doMock('./retry_unauthorized', () => ({ + createInternalErrorHandler: createInternalErrorHandlerMock, +})); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index b9fb8a21f0a8b..2f847f890638a 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -6,10 +6,14 @@ * Side Public License, v 1. */ -import { configureClientMock } from './cluster_client.test.mocks'; +import { + configureClientMock, + createTransportMock, + createInternalErrorHandlerMock, +} from './cluster_client.test.mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { httpServerMock } from '../../http/http_server.mocks'; -import { GetAuthHeaders } from '../../http'; +import { httpServiceMock } from '../../http/http_service.mock'; import { elasticsearchClientMock } from './mocks'; import { ClusterClient } from './cluster_client'; import { ElasticsearchClientConfig } from './client_config'; @@ -31,15 +35,19 @@ const createConfig = ( describe('ClusterClient', () => { let logger: ReturnType; - let getAuthHeaders: jest.MockedFunction; + let authHeaders: ReturnType; let internalClient: ReturnType; let scopedClient: ReturnType; + const mockTransport = { mockTransport: true }; + beforeEach(() => { logger = loggingSystemMock.createLogger(); internalClient = elasticsearchClientMock.createInternalClient(); scopedClient = elasticsearchClientMock.createInternalClient(); - getAuthHeaders = jest.fn().mockImplementation(() => ({ + + authHeaders = httpServiceMock.createAuthHeaderStorage(); + authHeaders.get.mockImplementation(() => ({ authorization: 'auth', foo: 'bar', })); @@ -47,16 +55,26 @@ describe('ClusterClient', () => { configureClientMock.mockImplementation((config, { scoped = false }) => { return scoped ? scopedClient : internalClient; }); + createTransportMock.mockReturnValue(mockTransport); }); afterEach(() => { configureClientMock.mockReset(); + createTransportMock.mockReset(); + createInternalErrorHandlerMock.mockReset(); }); it('creates a single internal and scoped client during initialization', () => { const config = createConfig(); const getExecutionContextMock = jest.fn(); - new ClusterClient(config, logger, 'custom-type', getAuthHeaders, getExecutionContextMock); + + new ClusterClient({ + config, + logger, + authHeaders, + type: 'custom-type', + getExecutionContext: getExecutionContextMock, + }); expect(configureClientMock).toHaveBeenCalledTimes(2); expect(configureClientMock).toHaveBeenCalledWith(config, { @@ -74,12 +92,12 @@ describe('ClusterClient', () => { describe('#asInternalUser', () => { it('returns the internal client', () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); expect(clusterClient.asInternalUser).toBe(internalClient); }); @@ -87,30 +105,90 @@ describe('ClusterClient', () => { describe('#asScoped', () => { it('returns a scoped cluster client bound to the request', () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); const request = httpServerMock.createKibanaRequest(); const scopedClusterClient = clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ headers: expect.any(Object) }); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: expect.any(Object), + Transport: mockTransport, + }); expect(scopedClusterClient.asInternalUser).toBe(clusterClient.asInternalUser); expect(scopedClusterClient.asCurrentUser).toBe(scopedClient.child.mock.results[0].value); }); + it('calls `createTransport` with the correct parameters', () => { + const getExecutionContext = jest.fn(); + const getUnauthorizedErrorHandler = jest.fn(); + const clusterClient = new ClusterClient({ + config: createConfig(), + logger, + type: 'custom-type', + authHeaders, + getExecutionContext, + getUnauthorizedErrorHandler, + }); + const request = httpServerMock.createKibanaRequest(); + + clusterClient.asScoped(request); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + expect(createTransportMock).toHaveBeenCalledWith({ + getExecutionContext, + getUnauthorizedErrorHandler: expect.any(Function), + }); + }); + + it('calls `createTransportcreateInternalErrorHandler` lazily', () => { + const getExecutionContext = jest.fn(); + const getUnauthorizedErrorHandler = jest.fn(); + const clusterClient = new ClusterClient({ + config: createConfig(), + logger, + type: 'custom-type', + authHeaders, + getExecutionContext, + getUnauthorizedErrorHandler, + }); + const request = httpServerMock.createKibanaRequest(); + + clusterClient.asScoped(request); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + expect(createTransportMock).toHaveBeenCalledWith({ + getExecutionContext, + getUnauthorizedErrorHandler: expect.any(Function), + }); + + const { getUnauthorizedErrorHandler: getHandler } = createTransportMock.mock.calls[0][0]; + + expect(createInternalErrorHandlerMock).not.toHaveBeenCalled(); + + getHandler(); + + expect(createInternalErrorHandlerMock).toHaveBeenCalledTimes(1); + expect(createInternalErrorHandlerMock).toHaveBeenCalledWith({ + request, + getHandler: getUnauthorizedErrorHandler, + setAuthHeaders: authHeaders.set, + }); + }); + it('returns a distinct scoped cluster client on each call', () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); const request = httpServerMock.createKibanaRequest(); const scopedClusterClient1 = clusterClient.asScoped(request); @@ -126,9 +204,9 @@ describe('ClusterClient', () => { const config = createConfig({ requestHeadersWhitelist: ['foo'], }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'bar', @@ -139,46 +217,50 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { ...DEFAULT_HEADERS, foo: 'bar', 'x-opaque-id': expect.any(String) }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { ...DEFAULT_HEADERS, foo: 'bar', 'x-opaque-id': expect.any(String) }, + }) + ); }); it('does not filter auth headers', () => { const config = createConfig({ requestHeadersWhitelist: ['authorization'], }); - getAuthHeaders.mockReturnValue({ + authHeaders.get.mockReturnValue({ authorization: 'auth', other: 'yep', }); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - authorization: 'auth', - other: 'yep', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + authorization: 'auth', + other: 'yep', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('respects auth headers precedence', () => { const config = createConfig({ requestHeadersWhitelist: ['authorization'], }); - getAuthHeaders.mockReturnValue({ + authHeaders.get.mockReturnValue({ authorization: 'auth', other: 'yep', }); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'override', @@ -188,14 +270,16 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - authorization: 'auth', - other: 'yep', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + authorization: 'auth', + other: 'yep', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('includes the `customHeaders` from the config without filtering them', () => { @@ -206,29 +290,31 @@ describe('ClusterClient', () => { }, requestHeadersWhitelist: ['authorization'], }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - foo: 'bar', - hello: 'dolly', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + foo: 'bar', + hello: 'dolly', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('adds the x-opaque-id header based on the request id', () => { const config = createConfig(); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'my-fake-id', requestUuid: 'ignore-this-id' }, }); @@ -236,12 +322,14 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - 'x-opaque-id': 'my-fake-id', - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + 'x-opaque-id': 'my-fake-id', + }, + }) + ); }); it('respect the precedence of auth headers over config headers', () => { @@ -252,24 +340,26 @@ describe('ClusterClient', () => { }, requestHeadersWhitelist: ['foo'], }); - getAuthHeaders.mockReturnValue({ + authHeaders.get.mockReturnValue({ foo: 'auth', }); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - foo: 'auth', - hello: 'dolly', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + foo: 'auth', + hello: 'dolly', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('respect the precedence of request headers over config headers', () => { @@ -280,9 +370,9 @@ describe('ClusterClient', () => { }, requestHeadersWhitelist: ['foo'], }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'request' }, }); @@ -290,14 +380,16 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - foo: 'request', - hello: 'dolly', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + foo: 'request', + hello: 'dolly', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('respect the precedence of config headers over default headers', () => { @@ -307,20 +399,22 @@ describe('ClusterClient', () => { [headerKey]: 'foo', }, }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest(); clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - [headerKey]: 'foo', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + [headerKey]: 'foo', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('respect the precedence of request headers over default headers', () => { @@ -328,9 +422,9 @@ describe('ClusterClient', () => { const config = createConfig({ requestHeadersWhitelist: [headerKey], }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ headers: { [headerKey]: 'foo' }, }); @@ -338,12 +432,14 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - [headerKey]: 'foo', - 'x-opaque-id': expect.any(String), - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + [headerKey]: 'foo', + 'x-opaque-id': expect.any(String), + }, + }) + ); }); it('respect the precedence of x-opaque-id header over config headers', () => { @@ -352,9 +448,9 @@ describe('ClusterClient', () => { 'x-opaque-id': 'from config', }, }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'request' }, kibanaRequestState: { requestId: 'from request', requestUuid: 'ignore-this-id' }, @@ -363,21 +459,23 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { - ...DEFAULT_HEADERS, - 'x-opaque-id': 'from request', - }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + ...DEFAULT_HEADERS, + 'x-opaque-id': 'from request', + }, + }) + ); }); it('filter headers when called with a `FakeRequest`', () => { const config = createConfig({ requestHeadersWhitelist: ['authorization'], }); - getAuthHeaders.mockReturnValue({}); + authHeaders.get.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = { headers: { authorization: 'auth', @@ -388,20 +486,22 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { ...DEFAULT_HEADERS, authorization: 'auth' }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { ...DEFAULT_HEADERS, authorization: 'auth' }, + }) + ); }); it('does not add auth headers when called with a `FakeRequest`', () => { const config = createConfig({ requestHeadersWhitelist: ['authorization', 'foo'], }); - getAuthHeaders.mockReturnValue({ + authHeaders.get.mockReturnValue({ authorization: 'auth', }); - const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const clusterClient = new ClusterClient({ config, logger, type: 'custom-type', authHeaders }); const request = { headers: { foo: 'bar', @@ -412,20 +512,22 @@ describe('ClusterClient', () => { clusterClient.asScoped(request); expect(scopedClient.child).toHaveBeenCalledTimes(1); - expect(scopedClient.child).toHaveBeenCalledWith({ - headers: { ...DEFAULT_HEADERS, foo: 'bar' }, - }); + expect(scopedClient.child).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { ...DEFAULT_HEADERS, foo: 'bar' }, + }) + ); }); }); describe('#close', () => { it('closes both underlying clients', async () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); await clusterClient.close(); @@ -436,12 +538,12 @@ describe('ClusterClient', () => { it('waits for both clients to close', async (done) => { expect.assertions(4); - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); let internalClientClosed = false; let scopedClientClosed = false; @@ -479,12 +581,12 @@ describe('ClusterClient', () => { }); it('return a rejected promise is any client rejects', async () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); internalClient.close.mockRejectedValue(new Error('error closing client')); @@ -494,12 +596,12 @@ describe('ClusterClient', () => { }); it('does nothing after the first call', async () => { - const clusterClient = new ClusterClient( - createConfig(), + const clusterClient = new ClusterClient({ + config: createConfig(), logger, - 'custom-type', - getAuthHeaders - ); + type: 'custom-type', + authHeaders, + }); await clusterClient.close(); diff --git a/src/core/server/elasticsearch/client/cluster_client.ts b/src/core/server/elasticsearch/client/cluster_client.ts index 1744d7a41841b..6bf74294ab6c1 100644 --- a/src/core/server/elasticsearch/client/cluster_client.ts +++ b/src/core/server/elasticsearch/client/cluster_client.ts @@ -8,7 +8,7 @@ import type { KibanaClient } from '@elastic/elasticsearch/lib/api/kibana'; import { Logger } from '../../logging'; -import { GetAuthHeaders, Headers, isKibanaRequest, isRealRequest } from '../../http'; +import { IAuthHeadersStorage, Headers, isKibanaRequest, isRealRequest } from '../../http'; import { ensureRawRequest, filterHeaders } from '../../http/router'; import { ScopeableRequest } from '../types'; import { ElasticsearchClient } from './types'; @@ -16,6 +16,12 @@ import { configureClient } from './configure_client'; import { ElasticsearchClientConfig } from './client_config'; import { ScopedClusterClient, IScopedClusterClient } from './scoped_cluster_client'; import { DEFAULT_HEADERS } from '../default_headers'; +import { + UnauthorizedErrorHandler, + createInternalErrorHandler, + InternalUnauthorizedErrorHandler, +} from './retry_unauthorized'; +import { createTransport } from './create_transport'; const noop = () => undefined; @@ -52,17 +58,35 @@ export interface ICustomClusterClient extends IClusterClient { /** @internal **/ export class ClusterClient implements ICustomClusterClient { - public readonly asInternalUser: KibanaClient; + private readonly config: ElasticsearchClientConfig; + private readonly authHeaders?: IAuthHeadersStorage; private readonly rootScopedClient: KibanaClient; + private readonly getUnauthorizedErrorHandler: () => UnauthorizedErrorHandler | undefined; + private readonly getExecutionContext: () => string | undefined; private isClosed = false; - constructor( - private readonly config: ElasticsearchClientConfig, - logger: Logger, - type: string, - private readonly getAuthHeaders: GetAuthHeaders = noop, - getExecutionContext: () => string | undefined = noop - ) { + public readonly asInternalUser: KibanaClient; + + constructor({ + config, + logger, + type, + authHeaders, + getExecutionContext = noop, + getUnauthorizedErrorHandler = noop, + }: { + config: ElasticsearchClientConfig; + logger: Logger; + type: string; + authHeaders?: IAuthHeadersStorage; + getExecutionContext?: () => string | undefined; + getUnauthorizedErrorHandler?: () => UnauthorizedErrorHandler | undefined; + }) { + this.config = config; + this.authHeaders = authHeaders; + this.getExecutionContext = getExecutionContext; + this.getUnauthorizedErrorHandler = getUnauthorizedErrorHandler; + this.asInternalUser = configureClient(config, { logger, type, getExecutionContext }); this.rootScopedClient = configureClient(config, { logger, @@ -74,8 +98,15 @@ export class ClusterClient implements ICustomClusterClient { asScoped(request: ScopeableRequest) { const scopedHeaders = this.getScopedHeaders(request); + + const transportClass = createTransport({ + getExecutionContext: this.getExecutionContext, + getUnauthorizedErrorHandler: this.createInternalErrorHandlerAccessor(request), + }); + const scopedClient = this.rootScopedClient.child({ headers: scopedHeaders, + Transport: transportClass, }); return new ScopedClusterClient(this.asInternalUser, scopedClient); } @@ -88,12 +119,26 @@ export class ClusterClient implements ICustomClusterClient { await Promise.all([this.asInternalUser.close(), this.rootScopedClient.close()]); } + private createInternalErrorHandlerAccessor = ( + request: ScopeableRequest + ): (() => InternalUnauthorizedErrorHandler) | undefined => { + if (!this.authHeaders) { + return undefined; + } + return () => + createInternalErrorHandler({ + request, + getHandler: this.getUnauthorizedErrorHandler, + setAuthHeaders: this.authHeaders!.set, + }); + }; + private getScopedHeaders(request: ScopeableRequest): Headers { let scopedHeaders: Headers; if (isRealRequest(request)) { const requestHeaders = ensureRawRequest(request).headers ?? {}; const requestIdHeaders = isKibanaRequest(request) ? { 'x-opaque-id': request.id } : {}; - const authHeaders = this.getAuthHeaders(request) ?? {}; + const authHeaders = this.authHeaders ? this.authHeaders.get(request) : {}; scopedHeaders = { ...filterHeaders(requestHeaders, this.config.requestHeadersWhitelist), diff --git a/src/core/server/elasticsearch/client/configure_client.test.mocks.ts b/src/core/server/elasticsearch/client/configure_client.test.mocks.ts index ef7ff09d030fe..7da0ddfe4cdcc 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.mocks.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.mocks.ts @@ -11,6 +11,11 @@ jest.doMock('./client_config', () => ({ parseClientOptions: parseClientOptionsMock, })); +export const createTransportMock = jest.fn(); +jest.doMock('./create_transport', () => ({ + createTransport: createTransportMock, +})); + export const ClientMock = jest.fn(); jest.doMock('@elastic/elasticsearch', () => { const actual = jest.requireActual('@elastic/elasticsearch'); diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index f252993415afa..31c64362f8a2b 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -11,7 +11,11 @@ jest.mock('./log_query_and_deprecation.ts', () => ({ instrumentEsQueryAndDeprecationLogger: jest.fn(), })); -import { parseClientOptionsMock, ClientMock } from './configure_client.test.mocks'; +import { + parseClientOptionsMock, + createTransportMock, + ClientMock, +} from './configure_client.test.mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import type { ElasticsearchClientConfig } from './client_config'; import { configureClient } from './configure_client'; @@ -78,6 +82,36 @@ describe('configureClient', () => { expect(client).toBe(ClientMock.mock.results[0].value); }); + it('calls `createTransport` with the correct parameters', () => { + const getExecutionContext = jest.fn(); + configureClient(config, { logger, type: 'test', scoped: false, getExecutionContext }); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + expect(createTransportMock).toHaveBeenCalledWith({ getExecutionContext }); + + createTransportMock.mockClear(); + + configureClient(config, { logger, type: 'test', scoped: true, getExecutionContext }); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + expect(createTransportMock).toHaveBeenCalledWith({ getExecutionContext }); + }); + + it('constructs a client using the Transport returned by `createTransport`', () => { + const mockedTransport = { mockTransport: true }; + createTransportMock.mockReturnValue(mockedTransport); + + const client = configureClient(config, { logger, type: 'test', scoped: false }); + + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + Transport: mockedTransport, + }) + ); + expect(client).toBe(ClientMock.mock.results[0].value); + }); + it('calls instrumentEsQueryAndDeprecationLogger', () => { const client = configureClient(config, { logger, type: 'test', scoped: false }); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index e48a36fa4fe58..58d1e228b98a0 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -6,17 +6,12 @@ * Side Public License, v 1. */ -import { Client, Transport, HttpConnection } from '@elastic/elasticsearch'; +import { Client, HttpConnection } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/lib/api/kibana'; -import type { - TransportRequestParams, - TransportRequestOptions, - TransportResult, -} from '@elastic/elasticsearch'; - import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; import { instrumentEsQueryAndDeprecationLogger } from './log_query_and_deprecation'; +import { createTransport } from './create_transport'; const noop = () => undefined; @@ -35,22 +30,7 @@ export const configureClient = ( } ): KibanaClient => { const clientOptions = parseClientOptions(config, scoped); - class KibanaTransport extends Transport { - request(params: TransportRequestParams, options?: TransportRequestOptions) { - const opts: TransportRequestOptions = options || {}; - const opaqueId = getExecutionContext(); - if (opaqueId && !opts.opaqueId) { - // rewrites headers['x-opaque-id'] if it presents - opts.opaqueId = opaqueId; - } - // Enforce the client to return TransportResult. - // It's required for bwc with responses in 7.x version. - if (opts.meta === undefined) { - opts.meta = true; - } - return super.request(params, opts) as Promise>; - } - } + const KibanaTransport = createTransport({ getExecutionContext }); const client = new Client({ ...clientOptions, diff --git a/src/core/server/elasticsearch/client/create_transport.test.mocks.ts b/src/core/server/elasticsearch/client/create_transport.test.mocks.ts new file mode 100644 index 0000000000000..c7c3d0d8e6b1d --- /dev/null +++ b/src/core/server/elasticsearch/client/create_transport.test.mocks.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +import type { TransportRequestParams, TransportRequestOptions } from '@elastic/transport'; +import type { TransportOptions } from '@elastic/transport/lib/Transport'; + +export const transportConstructorMock: jest.MockedFunction<(options: TransportOptions) => void> = + jest.fn(); +export const transportRequestMock = jest.fn(); + +class TransportMock { + constructor(options: TransportOptions) { + transportConstructorMock(options); + } + + request(params: TransportRequestParams, options?: TransportRequestOptions) { + return transportRequestMock(params, options); + } +} + +jest.doMock('@elastic/elasticsearch', () => { + const realModule = jest.requireActual('@elastic/elasticsearch'); + return { + ...realModule, + Transport: TransportMock, + }; +}); diff --git a/src/core/server/elasticsearch/client/create_transport.test.ts b/src/core/server/elasticsearch/client/create_transport.test.ts new file mode 100644 index 0000000000000..df2717e7ce9e6 --- /dev/null +++ b/src/core/server/elasticsearch/client/create_transport.test.ts @@ -0,0 +1,469 @@ +/* + * 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. + */ + +import { transportConstructorMock, transportRequestMock } from './create_transport.test.mocks'; + +import { errors } from '@elastic/elasticsearch'; +import type { BaseConnectionPool } from '@elastic/elasticsearch'; +import type { InternalUnauthorizedErrorHandler } from './retry_unauthorized'; +import { createTransport, ErrorHandlerAccessor } from './create_transport'; + +const createConnectionPool = () => { + return { _connectionPool: 'mocked' } as unknown as BaseConnectionPool; +}; + +const baseConstructorParams = { + connectionPool: createConnectionPool(), +}; + +const createUnauthorizedError = () => { + return new errors.ResponseError({ + statusCode: 401, + warnings: [], + meta: {} as any, + }); +}; + +describe('createTransport', () => { + let getUnauthorizedErrorHandler: jest.MockedFunction; + let getExecutionContext: jest.MockedFunction<() => string | undefined>; + + beforeEach(() => { + getUnauthorizedErrorHandler = jest.fn(); + getExecutionContext = jest.fn(); + }); + + afterEach(() => { + transportConstructorMock.mockReset(); + transportRequestMock.mockReset(); + }); + + const createTransportClass = () => { + return createTransport({ + getUnauthorizedErrorHandler, + getExecutionContext, + }); + }; + + describe('#constructor', () => { + it('calls the parent constructor with the passed options', () => { + const transportClass = createTransportClass(); + + const options = { + connectionPool: createConnectionPool(), + maxRetries: 42, + }; + + new transportClass(options); + + expect(transportConstructorMock).toHaveBeenCalledTimes(1); + expect(transportConstructorMock).toHaveBeenCalledWith(options); + }); + + it('omits the headers when calling the parent constructor', () => { + const transportClass = createTransportClass(); + + const options = { + connectionPool: createConnectionPool(), + maxRetries: 42, + headers: { + foo: 'bar', + }, + }; + + new transportClass(options); + + const { headers, ...optionsWithoutHeaders } = options; + + expect(transportConstructorMock).toHaveBeenCalledTimes(1); + expect(transportConstructorMock).toHaveBeenCalledWith(optionsWithoutHeaders); + }); + }); + + describe('#request', () => { + it('calls `super.request`', async () => { + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestOptions = { method: 'GET', path: '/' }; + + await transport.request(requestOptions); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + }); + + it('does not mutate the arguments', async () => { + const transportClass = createTransportClass(); + const constructorHeaders = { over: '9000', shared: 'from-constructor' }; + const transport = new transportClass({ + ...baseConstructorParams, + headers: constructorHeaders, + }); + + const requestParams = { method: 'GET', path: '/' }; + const options = { + headers: { hello: 'dolly', shared: 'from-options' }, + }; + + await transport.request(requestParams, options); + + expect(requestParams).toEqual({ method: 'GET', path: '/' }); + expect(options).toEqual({ headers: { hello: 'dolly', shared: 'from-options' } }); + }); + + describe('`meta` option', () => { + it('adds `meta: true` to the options when not provided', async () => { + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestOptions = { method: 'GET', path: '/' }; + + await transport.request(requestOptions, {}); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + meta: true, + }) + ); + }); + + it('does not add `meta: true` to the options when provided', async () => { + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await transport.request(requestParams, { meta: false }); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + meta: false, + }) + ); + }); + }); + + describe('`opaqueId` option', () => { + it('uses the value from the options when provided', async () => { + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await transport.request(requestParams, { opaqueId: 'some-opaque-id' }); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + opaqueId: 'some-opaque-id', + }) + ); + }); + + it('uses the value from getExecutionContext when provided', async () => { + getExecutionContext.mockReturnValue('opaque-id-from-exec-context'); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await transport.request(requestParams, {}); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + opaqueId: 'opaque-id-from-exec-context', + }) + ); + }); + + it('uses the value from the options when provided both by the options and execution context', async () => { + getExecutionContext.mockReturnValue('opaque-id-from-exec-context'); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await transport.request(requestParams, { opaqueId: 'opaque-id-from-options' }); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + opaqueId: 'opaque-id-from-options', + }) + ); + }); + }); + + describe('`headers` option', () => { + it('uses the headers from the options when provided', async () => { + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + const headers = { foo: 'bar', hello: 'dolly' }; + + await transport.request(requestParams, { headers }); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + headers, + }) + ); + }); + + it('uses the headers passed to the constructor when provided', async () => { + const transportClass = createTransportClass(); + const headers = { over: '9000', because: 'we can' }; + const transport = new transportClass({ ...baseConstructorParams, headers }); + const requestParams = { method: 'GET', path: '/' }; + + await transport.request(requestParams, {}); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + headers, + }) + ); + }); + + it('merges the headers from the constructor and from the options', async () => { + const transportClass = createTransportClass(); + const constructorHeaders = { over: '9000', shared: 'from-constructor' }; + const transport = new transportClass({ + ...baseConstructorParams, + headers: constructorHeaders, + }); + + const requestParams = { method: 'GET', path: '/' }; + const requestHeaders = { hello: 'dolly', shared: 'from-options' }; + + await transport.request(requestParams, { headers: requestHeaders }); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(transportRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + headers: { + over: '9000', + hello: 'dolly', + shared: 'from-options', + }, + }) + ); + }); + }); + }); + + describe('unauthorized error handler', () => { + it('does not call the handler if the error is not an `unauthorized`', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'notHandled' }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + transportRequestMock.mockImplementation(() => { + throw new Error('woups'); + }); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).rejects.toThrowError('woups'); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not attempt to retry the call if no handler is provided', async () => { + transportRequestMock.mockImplementation(() => { + throw new Error('woups'); + }); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).rejects.toThrowError('woups'); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + }); + + it('calls the handler if the error is an `unauthorized`', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'notHandled' }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + transportRequestMock.mockImplementation(() => { + throw error; + }); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).rejects.toThrowError(error); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(error); + }); + + it('does not retry the call if the handler returns `notHandled`', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'notHandled' }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + transportRequestMock.mockImplementation(() => { + throw error; + }); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).rejects.toThrowError(error); + + expect(transportRequestMock).toHaveBeenCalledTimes(1); + }); + + it('retries the call if the handler returns `retry` and return result from the retry', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'retry', authHeaders: {} }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + const retryResult = { body: 'some dummy content' }; + + transportRequestMock + .mockImplementationOnce(() => { + throw error; + }) + .mockResolvedValueOnce(retryResult); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).resolves.toEqual(retryResult); + + expect(transportRequestMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry more than once even in case of unauthorized errors', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'retry', authHeaders: {} }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + transportRequestMock.mockImplementation(() => { + throw error; + }); + + const transportClass = createTransportClass(); + const transport = new transportClass(baseConstructorParams); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).rejects.toThrowError(error); + + expect(transportRequestMock).toHaveBeenCalledTimes(2); + }); + + it('updates the headers for the second internal call in case of `retry`', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'retry', authHeaders: { authorization: 'retry' } }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + const retryResult = { body: 'some dummy content' }; + + transportRequestMock + .mockImplementationOnce(() => { + throw error; + }) + .mockResolvedValueOnce(retryResult); + + const initialHeaders = { authorization: 'initial', foo: 'bar' }; + const transportClass = createTransportClass(); + const transport = new transportClass({ ...baseConstructorParams, headers: initialHeaders }); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).resolves.toEqual(retryResult); + + expect(transportRequestMock).toHaveBeenCalledTimes(2); + expect(transportRequestMock).toHaveBeenNthCalledWith( + 1, + requestParams, + expect.objectContaining({ + headers: initialHeaders, + }) + ); + expect(transportRequestMock).toHaveBeenNthCalledWith( + 2, + requestParams, + expect.objectContaining({ + headers: { authorization: 'retry', foo: 'bar' }, + }) + ); + }); + + it('updates the headers for next requests in case of `retry`', async () => { + const handler: jest.MockedFunction = jest.fn(); + handler.mockReturnValue({ type: 'retry', authHeaders: { authorization: 'retry' } }); + + getUnauthorizedErrorHandler.mockReturnValue(handler); + + const error = createUnauthorizedError(); + + const retryResult = { body: 'some dummy content' }; + + transportRequestMock + .mockImplementationOnce(() => { + throw error; + }) + .mockResolvedValue(retryResult); + + const initialHeaders = { authorization: 'initial', foo: 'bar' }; + const transportClass = createTransportClass(); + const transport = new transportClass({ ...baseConstructorParams, headers: initialHeaders }); + const requestParams = { method: 'GET', path: '/' }; + + await expect(transport.request(requestParams, {})).resolves.toEqual(retryResult); + await expect(transport.request(requestParams, {})).resolves.toEqual(retryResult); + + expect(transportRequestMock).toHaveBeenCalledTimes(3); + expect(transportRequestMock).toHaveBeenNthCalledWith( + 3, + requestParams, + expect.objectContaining({ + headers: { authorization: 'retry', foo: 'bar' }, + }) + ); + }); + }); +}); diff --git a/src/core/server/elasticsearch/client/create_transport.ts b/src/core/server/elasticsearch/client/create_transport.ts new file mode 100644 index 0000000000000..d72fae58c88cf --- /dev/null +++ b/src/core/server/elasticsearch/client/create_transport.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +import type { IncomingHttpHeaders } from 'http'; +import type { + TransportRequestParams, + TransportRequestOptions, + TransportResult, +} from '@elastic/transport'; +import type { TransportOptions } from '@elastic/transport/lib/Transport'; +import { Transport } from '@elastic/elasticsearch'; +import { isUnauthorizedError } from './errors'; +import { InternalUnauthorizedErrorHandler, isRetryResult } from './retry_unauthorized'; + +type TransportClass = typeof Transport; + +export type ErrorHandlerAccessor = () => InternalUnauthorizedErrorHandler; + +const noop = () => undefined; + +export const createTransport = ({ + getExecutionContext = noop, + getUnauthorizedErrorHandler, +}: { + getExecutionContext?: () => string | undefined; + getUnauthorizedErrorHandler?: ErrorHandlerAccessor; +}): TransportClass => { + class KibanaTransport extends Transport { + private headers: IncomingHttpHeaders = {}; + + constructor(options: TransportOptions) { + const { headers = {}, ...otherOptions } = options; + super(otherOptions); + this.headers = headers; + } + + async request(params: TransportRequestParams, options?: TransportRequestOptions) { + const opts: TransportRequestOptions = options ? { ...options } : {}; + const opaqueId = getExecutionContext(); + if (opaqueId && !opts.opaqueId) { + // rewrites headers['x-opaque-id'] if it presents + opts.opaqueId = opaqueId; + } + // Enforce the client to return TransportResult. + // It's required for bwc with responses in 7.x version. + if (opts.meta === undefined) { + opts.meta = true; + } + + // add stored headers to the options + opts.headers = { + ...this.headers, + ...options?.headers, + }; + + try { + return (await super.request(params, opts)) as TransportResult; + } catch (e) { + if (isUnauthorizedError(e)) { + const unauthorizedErrorHandler = getUnauthorizedErrorHandler + ? getUnauthorizedErrorHandler() + : undefined; + if (unauthorizedErrorHandler) { + const result = await unauthorizedErrorHandler(e); + if (isRetryResult(result)) { + this.headers = { + ...this.headers, + ...result.authHeaders, + }; + const retryOpts = { ...opts }; + retryOpts.headers = { + ...this.headers, + ...options?.headers, + }; + return (await super.request(params, retryOpts)) as TransportResult; + } + } + } + throw e; + } + } + } + + return KibanaTransport; +}; diff --git a/src/core/server/elasticsearch/client/errors.ts b/src/core/server/elasticsearch/client/errors.ts index 21452af770ff4..66ae088142d16 100644 --- a/src/core/server/elasticsearch/client/errors.ts +++ b/src/core/server/elasticsearch/client/errors.ts @@ -8,6 +8,7 @@ import { errors } from '@elastic/elasticsearch'; +/** @public */ export type UnauthorizedError = errors.ResponseError & { statusCode: 401; }; diff --git a/src/core/server/elasticsearch/client/index.ts b/src/core/server/elasticsearch/client/index.ts index 123c498f1ee21..18e2c1efd749c 100644 --- a/src/core/server/elasticsearch/client/index.ts +++ b/src/core/server/elasticsearch/client/index.ts @@ -24,3 +24,13 @@ export type { IClusterClient, ICustomClusterClient } from './cluster_client'; export { configureClient } from './configure_client'; export { getRequestDebugMeta, getErrorMessage } from './log_query_and_deprecation'; export { retryCallCluster, migrationRetryCallCluster } from './retry_call_cluster'; +export type { + UnauthorizedErrorHandlerOptions, + UnauthorizedErrorHandlerResultRetryParams, + UnauthorizedErrorHandlerRetryResult, + UnauthorizedErrorHandlerNotHandledResult, + UnauthorizedErrorHandlerResult, + UnauthorizedErrorHandlerToolkit, + UnauthorizedErrorHandler, +} from './retry_unauthorized'; +export type { UnauthorizedError } from './errors'; diff --git a/src/core/server/elasticsearch/client/retry_unauthorized.test.ts b/src/core/server/elasticsearch/client/retry_unauthorized.test.ts new file mode 100644 index 0000000000000..2ef8b81c26f26 --- /dev/null +++ b/src/core/server/elasticsearch/client/retry_unauthorized.test.ts @@ -0,0 +1,197 @@ +/* + * 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. + */ + +import type { SetAuthHeaders } from '../../http'; +import { httpServerMock } from '../../http/http_server.mocks'; +import type { UnauthorizedError } from './errors'; +import { + createInternalErrorHandler, + isRetryResult, + isNotHandledResult, + toolkit, +} from './retry_unauthorized'; + +const createUnauthorizedError = (): UnauthorizedError => { + return { statusCode: 401 } as UnauthorizedError; +}; + +describe('createInternalErrorHandler', () => { + let setAuthHeaders: jest.MockedFunction; + + beforeEach(() => { + setAuthHeaders = jest.fn(); + }); + + it('calls and returns the result from the provided handler', async () => { + const handlerResponse = toolkit.retry({ authHeaders: { foo: 'bar' } }); + const handler = jest.fn().mockReturnValue(handlerResponse); + const request = httpServerMock.createKibanaRequest(); + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + const result = await internalHandler(error); + + expect(result).toEqual(handlerResponse); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ request, error }, expect.any(Object)); + }); + + it('calls `setAuthHeaders` when the handler returns `retry`', async () => { + const handlerResponse = toolkit.retry({ authHeaders: { foo: 'bar' } }); + const handler = jest.fn().mockReturnValue(handlerResponse); + const request = httpServerMock.createKibanaRequest(); + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + await internalHandler(error); + + expect(setAuthHeaders).toHaveBeenCalledTimes(1); + expect(setAuthHeaders).toHaveBeenCalledWith(request, handlerResponse.authHeaders); + }); + + it('does not call `setAuthHeaders` when the handler returns `notHandled`', async () => { + const handlerResponse = toolkit.notHandled(); + const handler = jest.fn().mockReturnValue(handlerResponse); + const request = httpServerMock.createKibanaRequest(); + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + await internalHandler(error); + + expect(setAuthHeaders).not.toHaveBeenCalled(); + }); + + it('returns `notHandled` if the handler throws', async () => { + const handler = jest.fn().mockImplementation(() => { + throw new Error('woups'); + }); + const request = httpServerMock.createKibanaRequest(); + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + const result = await internalHandler(error); + + expect(isNotHandledResult(result)).toBe(true); + }); + + it('handles asynchronous handlers', async () => { + const handlerResponse = toolkit.retry({ authHeaders: { foo: 'bar' } }); + const handler = jest.fn().mockResolvedValue(handlerResponse); + const request = httpServerMock.createKibanaRequest(); + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + const result = await internalHandler(error); + + expect(result).toEqual(handlerResponse); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ request, error }, expect.any(Object)); + }); + + it('returns `notHandled` without calling the provided handler for fake requests', async () => { + const handler = jest.fn(); + const fakeRequest = { + headers: { + authorization: 'foobar', + }, + }; + + const internalHandler = createInternalErrorHandler({ + getHandler: () => handler, + request: fakeRequest, + setAuthHeaders, + }); + + const result = await internalHandler(createUnauthorizedError()); + + expect(isNotHandledResult(result)).toBe(true); + expect(handler).not.toHaveBeenCalled(); + }); + + it('checks the presence of a registered handler for each error', async () => { + const handlerResponse = toolkit.retry({ authHeaders: { foo: 'bar' } }); + const handler = jest.fn().mockResolvedValue(handlerResponse); + + const request = httpServerMock.createKibanaRequest(); + + const getHandler = jest.fn().mockReturnValueOnce(undefined).mockReturnValueOnce(handler); + + const internalHandler = createInternalErrorHandler({ + getHandler, + request, + setAuthHeaders, + }); + + const error = createUnauthorizedError(); + let result = await internalHandler(error); + + expect(isNotHandledResult(result)).toBe(true); + expect(handler).not.toHaveBeenCalled(); + + result = await internalHandler(error); + expect(handler).toHaveBeenCalledTimes(1); + expect(isRetryResult(result)).toBe(true); + }); +}); + +describe('isRetryResult', () => { + it('returns `true` for a `retry` result', () => { + expect( + isRetryResult( + toolkit.retry({ + authHeaders: { foo: 'bar' }, + }) + ) + ).toBe(true); + }); + + it('returns `false` for a `notHandled` result', () => { + expect(isRetryResult(toolkit.notHandled())).toBe(false); + }); +}); + +describe('isNotHandledResult', () => { + it('returns `false` for a `retry` result', () => { + expect( + isNotHandledResult( + toolkit.retry({ + authHeaders: { foo: 'bar' }, + }) + ) + ).toBe(false); + }); + + it('returns `true` for a `notHandled` result', () => { + expect(isNotHandledResult(toolkit.notHandled())).toBe(true); + }); +}); diff --git a/src/core/server/elasticsearch/client/retry_unauthorized.ts b/src/core/server/elasticsearch/client/retry_unauthorized.ts new file mode 100644 index 0000000000000..6e5b227de367d --- /dev/null +++ b/src/core/server/elasticsearch/client/retry_unauthorized.ts @@ -0,0 +1,137 @@ +/* + * 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. + */ + +import { MaybePromise } from '@kbn/utility-types'; +import { AuthHeaders, KibanaRequest, SetAuthHeaders, isRealRequest } from '../../http'; +import { ScopeableRequest } from '../types'; +import { UnauthorizedError } from './errors'; + +/** + * @public + */ +export interface UnauthorizedErrorHandlerOptions { + error: UnauthorizedError; + request: KibanaRequest; +} + +/** + * @public + */ +export interface UnauthorizedErrorHandlerResultRetryParams { + authHeaders: AuthHeaders; +} + +/** + * @public + */ +export interface UnauthorizedErrorHandlerRetryResult + extends UnauthorizedErrorHandlerResultRetryParams { + type: 'retry'; +} + +/** + * @public + */ +export interface UnauthorizedErrorHandlerNotHandledResult { + type: 'notHandled'; +} + +/** + * @public + */ +export type UnauthorizedErrorHandlerResult = + | UnauthorizedErrorHandlerRetryResult + | UnauthorizedErrorHandlerNotHandledResult; + +/** + * Toolkit passed to a {@link UnauthorizedErrorHandler} used to generate responses from the handler + * @public + */ +export interface UnauthorizedErrorHandlerToolkit { + /** + * The handler cannot handle the error, or was not able to authenticate. + */ + notHandled: () => UnauthorizedErrorHandlerNotHandledResult; + /** + * The handler was able to authenticate. Will retry the failed request with new auth headers + */ + retry: (params: UnauthorizedErrorHandlerResultRetryParams) => UnauthorizedErrorHandlerRetryResult; +} + +/** + * A handler used to handle unauthorized error returned by elasticsearch + * + * @public + */ +export type UnauthorizedErrorHandler = ( + options: UnauthorizedErrorHandlerOptions, + toolkit: UnauthorizedErrorHandlerToolkit +) => MaybePromise; + +/** @internal */ +export type InternalUnauthorizedErrorHandler = ( + error: UnauthorizedError +) => MaybePromise; + +/** @internal */ +export const toolkit: UnauthorizedErrorHandlerToolkit = { + notHandled: () => ({ type: 'notHandled' }), + retry: ({ authHeaders }) => ({ + type: 'retry', + authHeaders, + }), +}; + +const notHandledInternalErrorHandler: InternalUnauthorizedErrorHandler = () => toolkit.notHandled(); + +/** + * Converts the public version of `UnauthorizedErrorHandler` to the internal one used by the ES client + * + * @internal + */ +export const createInternalErrorHandler = ({ + getHandler, + request, + setAuthHeaders, +}: { + getHandler: () => UnauthorizedErrorHandler | undefined; + request: ScopeableRequest; + setAuthHeaders: SetAuthHeaders; +}): InternalUnauthorizedErrorHandler => { + // we don't want to support 401 retry for fake requests + if (!isRealRequest(request)) { + return notHandledInternalErrorHandler; + } + return async (error) => { + try { + const handler = getHandler(); + if (!handler) { + return toolkit.notHandled(); + } + const result = await handler({ request, error }, toolkit); + if (isRetryResult(result)) { + setAuthHeaders(request, result.authHeaders); + } + return result; + } catch (e) { + return toolkit.notHandled(); + } + }; +}; + +export const isRetryResult = ( + result: UnauthorizedErrorHandlerResult +): result is UnauthorizedErrorHandlerRetryResult => { + return result.type === 'retry'; +}; + +export const isNotHandledResult = ( + result: UnauthorizedErrorHandlerResult +): result is UnauthorizedErrorHandlerNotHandledResult => { + return result.type === 'notHandled'; +}; diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 8d70e0bcbd066..f217dbe35c7e9 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -19,6 +19,7 @@ import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; import { InternalElasticsearchServiceSetup, + ElasticsearchServiceSetup, ElasticsearchStatusMeta, ElasticsearchServicePreboot, } from './types'; @@ -27,18 +28,23 @@ import { ServiceStatus, ServiceStatusLevels } from '../status'; type MockedElasticSearchServicePreboot = jest.Mocked; -export interface MockedElasticSearchServiceSetup { +export type MockedElasticSearchServiceSetup = jest.Mocked< + Omit +> & { legacy: { config$: BehaviorSubject; }; -} +}; -type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup & { +export interface MockedElasticSearchServiceStart { + legacy: { + config$: BehaviorSubject; + }; client: ClusterClientMock; createClient: jest.MockedFunction< (name: string, config?: Partial) => CustomClusterClientMock >; -}; +} const createPrebootContractMock = () => { const prebootContract: MockedElasticSearchServicePreboot = { @@ -53,6 +59,7 @@ const createPrebootContractMock = () => { const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = { + setUnauthorizedErrorHandler: jest.fn(), legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, @@ -79,7 +86,9 @@ const createInternalPrebootContractMock = createPrebootContractMock; type MockedInternalElasticSearchServiceSetup = jest.Mocked; const createInternalSetupContractMock = () => { - const setupContract: MockedInternalElasticSearchServiceSetup = { + const setupContract = createSetupContractMock(); + const internalSetupContract: MockedInternalElasticSearchServiceSetup = { + ...setupContract, esNodesCompatibility$: new BehaviorSubject({ isCompatible: true, incompatibleNodes: [], @@ -90,11 +99,8 @@ const createInternalSetupContractMock = () => { level: ServiceStatusLevels.available, summary: 'Elasticsearch is available', }), - legacy: { - ...createSetupContractMock().legacy, - }, }; - return setupContract; + return internalSetupContract; }; const createInternalStartContractMock = createStartContractMock; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index ce5672ad30519..def2c400258b5 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -127,7 +127,9 @@ describe('#preboot', () => { expect(clusterClient).toBe(mockClusterClientInstance); expect(MockClusterClient).toHaveBeenCalledTimes(1); - expect(MockClusterClient.mock.calls[0][0]).toEqual(expect.objectContaining(customConfig)); + expect(MockClusterClient.mock.calls[0][0]).toEqual( + expect.objectContaining({ config: expect.objectContaining(customConfig) }) + ); }); it('creates a new client on each call', async () => { @@ -151,7 +153,7 @@ describe('#preboot', () => { }; prebootContract.createClient('some-custom-type', customConfig); - const config = MockClusterClient.mock.calls[0][0]; + const config = MockClusterClient.mock.calls[0][0].config; expect(config).toMatchInlineSnapshot(` Object { @@ -334,7 +336,9 @@ describe('#start', () => { expect(clusterClient).toBe(mockClusterClientInstance); expect(MockClusterClient).toHaveBeenCalledTimes(1); - expect(MockClusterClient.mock.calls[0][0]).toEqual(expect.objectContaining(customConfig)); + expect(MockClusterClient.mock.calls[0][0]).toEqual( + expect.objectContaining({ config: expect.objectContaining(customConfig) }) + ); }); it('creates a new client on each call', async () => { await elasticsearchService.setup(setupDeps); @@ -365,7 +369,7 @@ describe('#start', () => { }; startContract.createClient('some-custom-type', customConfig); - const config = MockClusterClient.mock.calls[0][0]; + const config = MockClusterClient.mock.calls[0][0].config; expect(config).toMatchInlineSnapshot(` Object { diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 014fa38646275..642b0ab75eaaf 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -16,7 +16,7 @@ import { Logger } from '../logging'; import { ClusterClient, ElasticsearchClientConfig } from './client'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; -import type { InternalHttpServiceSetup, GetAuthHeaders } from '../http'; +import type { InternalHttpServiceSetup, IAuthHeadersStorage } from '../http'; import type { InternalExecutionContextSetup, IExecutionContext } from '../execution_context'; import { InternalElasticsearchServicePreboot, @@ -28,6 +28,7 @@ import { pollEsNodesVersion } from './version_check/ensure_es_version'; import { calculateStatus$ } from './status'; import { isValidConnection } from './is_valid_connection'; import { isInlineScriptingEnabled } from './is_scripting_enabled'; +import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; export interface SetupDeps { http: InternalHttpServiceSetup; @@ -42,10 +43,11 @@ export class ElasticsearchService private readonly config$: Observable; private stop$ = new Subject(); private kibanaVersion: string; - private getAuthHeaders?: GetAuthHeaders; + private authHeaders?: IAuthHeadersStorage; private executionContextClient?: IExecutionContext; private esNodesCompatibility$?: Observable; private client?: ClusterClient; + private unauthorizedErrorHandler?: UnauthorizedErrorHandler; constructor(private readonly coreContext: CoreContext) { this.kibanaVersion = coreContext.env.packageInfo.version; @@ -76,7 +78,7 @@ export class ElasticsearchService const config = await this.config$.pipe(first()).toPromise(); - this.getAuthHeaders = deps.http.getAuthHeaders; + this.authHeaders = deps.http.authRequestHeaders; this.executionContextClient = deps.executionContext; this.client = this.createClusterClient('data', config); @@ -96,6 +98,12 @@ export class ElasticsearchService }, esNodesCompatibility$, status$: calculateStatus$(esNodesCompatibility$), + setUnauthorizedErrorHandler: (handler) => { + if (this.unauthorizedErrorHandler) { + throw new Error('setUnauthorizedErrorHandler can only be called once.'); + } + this.unauthorizedErrorHandler = handler; + }, }; } @@ -153,12 +161,13 @@ export class ElasticsearchService clientConfig?: Partial ) { const config = clientConfig ? merge({}, baseConfig, clientConfig) : baseConfig; - return new ClusterClient( + return new ClusterClient({ config, - this.coreContext.logger.get('elasticsearch'), + logger: this.coreContext.logger.get('elasticsearch'), type, - this.getAuthHeaders, - () => this.executionContextClient?.getAsHeader() - ); + authHeaders: this.authHeaders, + getExecutionContext: () => this.executionContextClient?.getAsHeader(), + getUnauthorizedErrorHandler: () => this.unauthorizedErrorHandler, + }); } } diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 69e8a549f20ac..c5e36dcb54fcd 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -39,6 +39,15 @@ export type { GetResponse, DeleteDocumentResponse, ElasticsearchErrorDetails, + // unauthorized error handler + UnauthorizedErrorHandlerOptions, + UnauthorizedErrorHandlerResultRetryParams, + UnauthorizedErrorHandlerRetryResult, + UnauthorizedErrorHandlerNotHandledResult, + UnauthorizedErrorHandlerResult, + UnauthorizedErrorHandlerToolkit, + UnauthorizedErrorHandler, + UnauthorizedError, } from './client'; export { getRequestDebugMeta, getErrorMessage } from './client'; export { pollEsNodesVersion } from './version_check/ensure_es_version'; diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 1b149cebfa957..c37e33426f7b9 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -13,6 +13,7 @@ import { ElasticsearchConfig } from './elasticsearch_config'; import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus } from '../status'; +import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; /** * @public @@ -55,6 +56,29 @@ export interface ElasticsearchServicePreboot { * @public */ export interface ElasticsearchServiceSetup { + /** + * Register a handler that will be called when unauthorized (401) errors are returned from any API + * call to elasticsearch performed on behalf of a user via a {@link IScopedClusterClient | scoped cluster client}. + * + * @example + * ```ts + * const handler: UnauthorizedErrorHandler = ({ request, error }, toolkit) => { + * const reauthenticationResult = await authenticator.reauthenticate(request, error); + * if (reauthenticationResult.succeeded()) { + * return toolkit.retry({ + * authHeaders: reauthenticationResult.authHeaders, + * }); + * } + * return toolkit.notHandled(); + * } + * + * coreSetup.elasticsearch.setUnauthorizedErrorHandler(handler); + * ``` + * + * @remarks The handler will only be invoked for scoped client bound to real {@link KibanaRequest | request} instances. + */ + setUnauthorizedErrorHandler: (handler: UnauthorizedErrorHandler) => void; + /** * @deprecated * Use {@link ElasticsearchServiceStart.legacy} instead. diff --git a/src/core/server/http/auth_headers_storage.ts b/src/core/server/http/auth_headers_storage.ts index d991f4298ba11..82bb8ca4fbb05 100644 --- a/src/core/server/http/auth_headers_storage.ts +++ b/src/core/server/http/auth_headers_storage.ts @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import { Request } from '@hapi/hapi'; import { KibanaRequest, ensureRawRequest } from './router'; import { AuthHeaders } from './lifecycle/auth'; @@ -18,11 +19,22 @@ import { AuthHeaders } from './lifecycle/auth'; export type GetAuthHeaders = (request: KibanaRequest) => AuthHeaders | undefined; /** @internal */ -export class AuthHeadersStorage { +export type SetAuthHeaders = (request: KibanaRequest, headers: AuthHeaders) => void; + +/** @internal */ +export interface IAuthHeadersStorage { + set: SetAuthHeaders; + get: GetAuthHeaders; +} + +/** @internal */ +export class AuthHeadersStorage implements IAuthHeadersStorage { private authHeadersCache = new WeakMap(); + public set = (request: KibanaRequest | Request, headers: AuthHeaders) => { this.authHeadersCache.set(ensureRawRequest(request), headers); }; + public get: GetAuthHeaders = (request) => { return this.authHeadersCache.get(ensureRawRequest(request)); }; diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 348b5c4af3e58..4623b09b19e29 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -42,7 +42,7 @@ import { createCookieSessionStorageFactory, } from './cookie_session_storage'; import { AuthStateStorage } from './auth_state_storage'; -import { AuthHeadersStorage, GetAuthHeaders } from './auth_headers_storage'; +import { AuthHeadersStorage, IAuthHeadersStorage } from './auth_headers_storage'; import { BasePath } from './base_path_service'; import { getEcsResponseLog } from './logging'; import { HttpServiceSetup, HttpServerInfo, HttpAuth } from './types'; @@ -71,7 +71,7 @@ export interface HttpServerSetup { registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; registerOnPreResponse: HttpServiceSetup['registerOnPreResponse']; - getAuthHeaders: GetAuthHeaders; + authRequestHeaders: IAuthHeadersStorage; auth: HttpAuth; getServerInfo: () => HttpServerInfo; } @@ -171,7 +171,7 @@ export class HttpServer { get: this.authState.get, isAuthenticated: this.authState.isAuthenticated, }, - getAuthHeaders: this.authRequestHeaders.get, + authRequestHeaders: this.authRequestHeaders, getServerInfo: () => ({ name: config.name, hostname: config.host, diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 1b6aa2de3c192..14baf9ba9257b 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -29,6 +29,7 @@ import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { configMock } from '../config/mocks'; import { ExternalUrlConfig } from '../external_url'; +import type { IAuthHeadersStorage } from './auth_headers_storage'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; @@ -44,10 +45,11 @@ export type HttpServiceSetupMock = jest.Mocked< createRouter: jest.MockedFunction<() => RouterMock>; }; export type InternalHttpServiceSetupMock = jest.Mocked< - Omit + Omit > & { basePath: BasePathMocked; createRouter: jest.MockedFunction<(path: string) => RouterMock>; + authRequestHeaders: jest.Mocked; }; export type HttpServiceStartMock = jest.Mocked & { basePath: BasePathMocked; @@ -78,6 +80,14 @@ const createAuthMock = () => { return mock; }; +const createAuthHeaderStorageMock = () => { + const mock: jest.Mocked = { + set: jest.fn(), + get: jest.fn(), + }; + return mock; +}; + const createInternalPrebootContractMock = () => { const mock: InternalHttpServicePrebootMock = { registerRoutes: jest.fn(), @@ -138,14 +148,14 @@ const createInternalSetupContractMock = () => { csp: CspConfig.DEFAULT, externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), - getAuthHeaders: jest.fn(), + authRequestHeaders: createAuthHeaderStorageMock(), getServerInfo: jest.fn(), registerPrebootRoutes: jest.fn(), registerRouterAfterListening: jest.fn(), }; mock.createCookieSessionStorageFactory.mockResolvedValue(sessionStorageMock.createFactory()); mock.createRouter.mockImplementation(() => mockRouter.create()); - mock.getAuthHeaders.mockReturnValue({ authorization: 'authorization-header' }); + mock.authRequestHeaders.get.mockReturnValue({ authorization: 'authorization-header' }); mock.getServerInfo.mockReturnValue({ hostname: 'localhost', name: 'kibana', @@ -258,5 +268,6 @@ export const httpServiceMock = { createOnPreResponseToolkit: createOnPreResponseToolkitMock, createOnPreRoutingToolkit: createOnPreRoutingToolkitMock, createAuthToolkit: createAuthToolkitMock, + createAuthHeaderStorage: createAuthHeaderStorageMock, createRouter: mockRouter.create, }; diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index a56071ed1d980..9fb78db33f972 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -9,7 +9,7 @@ export { config, HttpConfig } from './http_config'; export type { HttpConfigType } from './http_config'; export { HttpService } from './http_service'; -export type { GetAuthHeaders } from './auth_headers_storage'; +export type { GetAuthHeaders, SetAuthHeaders, IAuthHeadersStorage } from './auth_headers_storage'; export type { AuthStatus, GetAuthState, IsAuthenticated } from './auth_state_storage'; export { isKibanaRequest, diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 89d0d72017082..f12533dba4286 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -9,7 +9,7 @@ import { IContextProvider, IContextContainer } from '../context'; import { ICspConfig } from '../csp'; import { GetAuthState, IsAuthenticated } from './auth_state_storage'; -import { GetAuthHeaders } from './auth_headers_storage'; +import { IAuthHeadersStorage } from './auth_headers_storage'; import { IRouter } from './router'; import { HttpServerSetup } from './http_server'; import { SessionStorageCookieOptions } from './cookie_session_storage'; @@ -398,7 +398,7 @@ export interface InternalHttpServiceSetup ) => IRouter; registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; - getAuthHeaders: GetAuthHeaders; + authRequestHeaders: IAuthHeadersStorage; registerRouteHandlerContext: < Context extends RequestHandlerContext, ContextName extends keyof Context @@ -407,6 +407,7 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; + registerPrebootRoutes(path: string, callback: (router: IRouter) => void): void; } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 2f2f78da7274e..76e48b9cadc0b 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -138,6 +138,14 @@ export type { ElasticsearchConfigPreboot, ElasticsearchErrorDetails, PollEsNodesVersionOptions, + UnauthorizedErrorHandlerOptions, + UnauthorizedErrorHandlerResultRetryParams, + UnauthorizedErrorHandlerRetryResult, + UnauthorizedErrorHandlerNotHandledResult, + UnauthorizedErrorHandlerResult, + UnauthorizedErrorHandlerToolkit, + UnauthorizedErrorHandler, + UnauthorizedError, } from './elasticsearch'; export type { IExternalUrlConfig, IExternalUrlPolicy } from './external_url'; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 28382d62e4ba7..99453f085ac0f 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -156,6 +156,7 @@ export function createPluginSetupContext( }, elasticsearch: { legacy: deps.elasticsearch.legacy, + setUnauthorizedErrorHandler: deps.elasticsearch.setUnauthorizedErrorHandler, }, executionContext: { withContext: deps.executionContext.withContext, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9cd5486ecf67b..7230d986c1b88 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -27,6 +27,7 @@ import { EcsEventKind } from '@kbn/logging'; import { EcsEventOutcome } from '@kbn/logging'; import { EcsEventType } from '@kbn/logging'; import { EnvironmentMode } from '@kbn/config'; +import { errors } from '@elastic/elasticsearch'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IncomingHttpHeaders } from 'http'; import type { KibanaClient } from '@elastic/elasticsearch/lib/api/kibana'; @@ -35,7 +36,7 @@ import { LoggerFactory } from '@kbn/logging'; import { LogLevel as LogLevel_2 } from '@kbn/logging'; import { LogMeta } from '@kbn/logging'; import { LogRecord } from '@kbn/logging'; -import type { MaybePromise } from '@kbn/utility-types'; +import { MaybePromise } from '@kbn/utility-types'; import { ObjectType } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { PackageInfo } from '@kbn/config'; @@ -939,6 +940,7 @@ export interface ElasticsearchServiceSetup { legacy: { readonly config$: Observable; }; + setUnauthorizedErrorHandler: (handler: UnauthorizedErrorHandler) => void; } // @public (undocumented) @@ -3088,6 +3090,49 @@ export interface UiSettingsServiceStart { // @public export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; +// @public (undocumented) +export type UnauthorizedError = errors.ResponseError & { + statusCode: 401; +}; + +// @public +export type UnauthorizedErrorHandler = (options: UnauthorizedErrorHandlerOptions, toolkit: UnauthorizedErrorHandlerToolkit) => MaybePromise; + +// @public (undocumented) +export interface UnauthorizedErrorHandlerNotHandledResult { + // (undocumented) + type: 'notHandled'; +} + +// @public (undocumented) +export interface UnauthorizedErrorHandlerOptions { + // (undocumented) + error: UnauthorizedError; + // (undocumented) + request: KibanaRequest; +} + +// @public (undocumented) +export type UnauthorizedErrorHandlerResult = UnauthorizedErrorHandlerRetryResult | UnauthorizedErrorHandlerNotHandledResult; + +// @public (undocumented) +export interface UnauthorizedErrorHandlerResultRetryParams { + // (undocumented) + authHeaders: AuthHeaders; +} + +// @public (undocumented) +export interface UnauthorizedErrorHandlerRetryResult extends UnauthorizedErrorHandlerResultRetryParams { + // (undocumented) + type: 'retry'; +} + +// @public +export interface UnauthorizedErrorHandlerToolkit { + notHandled: () => UnauthorizedErrorHandlerNotHandledResult; + retry: (params: UnauthorizedErrorHandlerResultRetryParams) => UnauthorizedErrorHandlerRetryResult; +} + // @public export interface UserProvidedValues { // (undocumented)