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
This commit is contained in:
Pierre Gayvallet 2022-01-18 14:40:12 +01:00 committed by GitHub
parent 17d2cd105f
commit b6060544cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1714 additions and 209 deletions

View file

@ -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) =&gt; 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)<!-- -->. |

View file

@ -0,0 +1,35 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) &gt; [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)<!-- -->.
<b>Signature:</b>
```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);
```

View file

@ -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 <code>start</code>. This should only be used inside handlers registered during <code>setup</code> that will only be executed after <code>start</code> 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) | |

View file

@ -0,0 +1,14 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedError](./kibana-plugin-core-server.unauthorizederror.md)
## UnauthorizedError type
<b>Signature:</b>
```typescript
export declare type UnauthorizedError = errors.ResponseError & {
statusCode: 401;
};
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandler](./kibana-plugin-core-server.unauthorizederrorhandler.md)
## UnauthorizedErrorHandler type
A handler used to handle unauthorized error returned by elasticsearch
<b>Signature:</b>
```typescript
export declare type UnauthorizedErrorHandler = (options: UnauthorizedErrorHandlerOptions, toolkit: UnauthorizedErrorHandlerToolkit) => MaybePromise<UnauthorizedErrorHandlerResult>;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerNotHandledResult](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md)
## UnauthorizedErrorHandlerNotHandledResult interface
<b>Signature:</b>
```typescript
export interface UnauthorizedErrorHandlerNotHandledResult
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [type](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md) | 'notHandled' | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerNotHandledResult](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.md) &gt; [type](./kibana-plugin-core-server.unauthorizederrorhandlernothandledresult.type.md)
## UnauthorizedErrorHandlerNotHandledResult.type property
<b>Signature:</b>
```typescript
type: 'notHandled';
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) &gt; [error](./kibana-plugin-core-server.unauthorizederrorhandleroptions.error.md)
## UnauthorizedErrorHandlerOptions.error property
<b>Signature:</b>
```typescript
error: UnauthorizedError;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md)
## UnauthorizedErrorHandlerOptions interface
<b>Signature:</b>
```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 | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerOptions](./kibana-plugin-core-server.unauthorizederrorhandleroptions.md) &gt; [request](./kibana-plugin-core-server.unauthorizederrorhandleroptions.request.md)
## UnauthorizedErrorHandlerOptions.request property
<b>Signature:</b>
```typescript
request: KibanaRequest;
```

View file

@ -0,0 +1,12 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerResult](./kibana-plugin-core-server.unauthorizederrorhandlerresult.md)
## UnauthorizedErrorHandlerResult type
<b>Signature:</b>
```typescript
export declare type UnauthorizedErrorHandlerResult = UnauthorizedErrorHandlerRetryResult | UnauthorizedErrorHandlerNotHandledResult;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerResultRetryParams](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md) &gt; [authHeaders](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md)
## UnauthorizedErrorHandlerResultRetryParams.authHeaders property
<b>Signature:</b>
```typescript
authHeaders: AuthHeaders;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerResultRetryParams](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.md)
## UnauthorizedErrorHandlerResultRetryParams interface
<b>Signature:</b>
```typescript
export interface UnauthorizedErrorHandlerResultRetryParams
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [authHeaders](./kibana-plugin-core-server.unauthorizederrorhandlerresultretryparams.authheaders.md) | AuthHeaders | |

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerRetryResult](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md)
## UnauthorizedErrorHandlerRetryResult interface
<b>Signature:</b>
```typescript
export interface UnauthorizedErrorHandlerRetryResult extends UnauthorizedErrorHandlerResultRetryParams
```
<b>Extends:</b> UnauthorizedErrorHandlerResultRetryParams
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [type](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md) | 'retry' | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerRetryResult](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.md) &gt; [type](./kibana-plugin-core-server.unauthorizederrorhandlerretryresult.type.md)
## UnauthorizedErrorHandlerRetryResult.type property
<b>Signature:</b>
```typescript
type: 'retry';
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [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
<b>Signature:</b>
```typescript
export interface UnauthorizedErrorHandlerToolkit
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [notHandled](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md) | () =&gt; UnauthorizedErrorHandlerNotHandledResult | The handler cannot handle the error, or was not able to authenticate. |
| [retry](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.retry.md) | (params: UnauthorizedErrorHandlerResultRetryParams) =&gt; UnauthorizedErrorHandlerRetryResult | The handler was able to authenticate. Will retry the failed request with new auth headers |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) &gt; [notHandled](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.nothandled.md)
## UnauthorizedErrorHandlerToolkit.notHandled property
The handler cannot handle the error, or was not able to authenticate.
<b>Signature:</b>
```typescript
notHandled: () => UnauthorizedErrorHandlerNotHandledResult;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UnauthorizedErrorHandlerToolkit](./kibana-plugin-core-server.unauthorizederrorhandlertoolkit.md) &gt; [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
<b>Signature:</b>
```typescript
retry: (params: UnauthorizedErrorHandlerResultRetryParams) => UnauthorizedErrorHandlerRetryResult;
```

View file

@ -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,
});
},
},
});

View file

@ -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,
}));

View file

@ -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<typeof loggingSystemMock.createLogger>;
let getAuthHeaders: jest.MockedFunction<GetAuthHeaders>;
let authHeaders: ReturnType<typeof httpServiceMock.createAuthHeaderStorage>;
let internalClient: ReturnType<typeof elasticsearchClientMock.createInternalClient>;
let scopedClient: ReturnType<typeof elasticsearchClientMock.createInternalClient>;
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('returns a distinct scoped cluster client on each call', () => {
const clusterClient = new ClusterClient(
createConfig(),
it('calls `createTransport` with the correct parameters', () => {
const getExecutionContext = jest.fn();
const getUnauthorizedErrorHandler = jest.fn();
const clusterClient = new ClusterClient({
config: createConfig(),
logger,
'custom-type',
getAuthHeaders
);
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({
config: createConfig(),
logger,
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();

View file

@ -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),

View file

@ -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');

View file

@ -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 });

View file

@ -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<TransportResult<any, any>>;
}
}
const KibanaTransport = createTransport({ getExecutionContext });
const client = new Client({
...clientOptions,

View file

@ -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,
};
});

View file

@ -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<ErrorHandlerAccessor>;
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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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<InternalUnauthorizedErrorHandler> = 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' },
})
);
});
});
});

View file

@ -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<any, any>;
} 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<any, any>;
}
}
}
throw e;
}
}
}
return KibanaTransport;
};

View file

@ -8,6 +8,7 @@
import { errors } from '@elastic/elasticsearch';
/** @public */
export type UnauthorizedError = errors.ResponseError & {
statusCode: 401;
};

View file

@ -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';

View file

@ -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<SetAuthHeaders>;
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);
});
});

View file

@ -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<UnauthorizedErrorHandlerResult>;
/** @internal */
export type InternalUnauthorizedErrorHandler = (
error: UnauthorizedError
) => MaybePromise<UnauthorizedErrorHandlerResult>;
/** @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';
};

View file

@ -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<ElasticsearchServicePreboot>;
export interface MockedElasticSearchServiceSetup {
export type MockedElasticSearchServiceSetup = jest.Mocked<
Omit<ElasticsearchServiceSetup, 'legacy'>
> & {
legacy: {
config$: BehaviorSubject<ElasticsearchConfig>;
};
}
};
type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup & {
export interface MockedElasticSearchServiceStart {
legacy: {
config$: BehaviorSubject<ElasticsearchConfig>;
};
client: ClusterClientMock;
createClient: jest.MockedFunction<
(name: string, config?: Partial<ElasticsearchClientConfig>) => 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<InternalElasticsearchServiceSetup>;
const createInternalSetupContractMock = () => {
const setupContract: MockedInternalElasticSearchServiceSetup = {
const setupContract = createSetupContractMock();
const internalSetupContract: MockedInternalElasticSearchServiceSetup = {
...setupContract,
esNodesCompatibility$: new BehaviorSubject<NodesVersionCompatibility>({
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;

View file

@ -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 {

View file

@ -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<ElasticsearchConfig>;
private stop$ = new Subject();
private kibanaVersion: string;
private getAuthHeaders?: GetAuthHeaders;
private authHeaders?: IAuthHeadersStorage;
private executionContextClient?: IExecutionContext;
private esNodesCompatibility$?: Observable<NodesVersionCompatibility>;
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<ElasticsearchClientConfig>
) {
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,
});
}
}

View file

@ -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';

View file

@ -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.

View file

@ -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<Request, AuthHeaders>();
public set = (request: KibanaRequest | Request, headers: AuthHeaders) => {
this.authHeadersCache.set(ensureRawRequest(request), headers);
};
public get: GetAuthHeaders = (request) => {
return this.authHeadersCache.get(ensureRawRequest(request));
};

View file

@ -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,

View file

@ -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<InternalHttpServiceSetup['basePath']>;
type AuthMocked = jest.Mocked<InternalHttpServiceSetup['auth']>;
@ -44,10 +45,11 @@ export type HttpServiceSetupMock = jest.Mocked<
createRouter: jest.MockedFunction<() => RouterMock>;
};
export type InternalHttpServiceSetupMock = jest.Mocked<
Omit<InternalHttpServiceSetup, 'basePath' | 'createRouter'>
Omit<InternalHttpServiceSetup, 'basePath' | 'createRouter' | 'authRequestHeaders'>
> & {
basePath: BasePathMocked;
createRouter: jest.MockedFunction<(path: string) => RouterMock>;
authRequestHeaders: jest.Mocked<IAuthHeadersStorage>;
};
export type HttpServiceStartMock = jest.Mocked<HttpServiceStart> & {
basePath: BasePathMocked;
@ -78,6 +80,14 @@ const createAuthMock = () => {
return mock;
};
const createAuthHeaderStorageMock = () => {
const mock: jest.Mocked<IAuthHeadersStorage> = {
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,
};

View file

@ -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,

View file

@ -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<Context>;
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<Context, ContextName>
) => RequestHandlerContextContainer;
registerPrebootRoutes(path: string, callback: (router: IRouter) => void): void;
}

View file

@ -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';

View file

@ -156,6 +156,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
},
elasticsearch: {
legacy: deps.elasticsearch.legacy,
setUnauthorizedErrorHandler: deps.elasticsearch.setUnauthorizedErrorHandler,
},
executionContext: {
withContext: deps.executionContext.withContext,

View file

@ -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<ElasticsearchConfig>;
};
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<UnauthorizedErrorHandlerResult>;
// @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<T = any> {
// (undocumented)