Restrict access to hapi Request in registerAuth (#38763) (#39151)

* Prevent exposing Hapi.Request to registerAuth.

Prevent exposing headers.authorization in KibanaRequest.
Introduce a mechanism to associate authorization headers with an
incoming request and retrieve its value to perform a request to
elasticsearch cluster.

* fix tests

* address @joshdover comments
This commit is contained in:
Mikhail Shustov 2019-06-18 15:04:31 +02:00 committed by GitHub
parent d3853ee9c6
commit 708666a5a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 989 additions and 252 deletions

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type AuthenticationHandler = (request: Readonly<Request>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export declare type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthHeaders](./kibana-plugin-server.authheaders.md)
## AuthHeaders type
Auth Headers map
<b>Signature:</b>
```typescript
export declare type AuthHeaders = Record<string, string>;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md) &gt; [headers](./kibana-plugin-server.authresultdata.headers.md)
## AuthResultData.headers property
Auth specific headers to authenticate a user against Elasticsearch.
<b>Signature:</b>
```typescript
headers: AuthHeaders;
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md)
## AuthResultData interface
Result of an incoming request authentication.
<b>Signature:</b>
```typescript
export interface AuthResultData
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [headers](./kibana-plugin-server.authresultdata.headers.md) | <code>AuthHeaders</code> | Auth specific headers to authenticate a user against Elasticsearch. |
| [state](./kibana-plugin-server.authresultdata.state.md) | <code>Record&lt;string, unknown&gt;</code> | Data to associate with an incoming request. Any downstream plugin may get access to the data. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md) &gt; [state](./kibana-plugin-server.authresultdata.state.md)
## AuthResultData.state property
Data to associate with an incoming request. Any downstream plugin may get access to the data.
<b>Signature:</b>
```typescript
state: Record<string, unknown>;
```

View file

@ -9,5 +9,5 @@ Authentication is successful with given credentials, allow request to pass throu
<b>Signature:</b>
```typescript
authenticated: (state?: object) => AuthResult;
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
```

View file

@ -16,7 +16,7 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(state?: object) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(data?: Partial&lt;AuthResultData&gt;) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | <code>(url: string) =&gt; AuthResult</code> | Authentication requires to interrupt request handling and redirect to a configured url |
| [rejected](./kibana-plugin-server.authtoolkit.rejected.md) | <code>(error: Error, options?: {</code><br/><code> statusCode?: number;</code><br/><code> }) =&gt; AuthResult</code> | Authentication is unsuccessful, fail the request with specified error. |

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `ClusterClient` class
<b>Signature:</b>
```typescript
constructor(config: ElasticsearchClientConfig, log: Logger);
constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders);
```
## Parameters
@ -18,4 +18,5 @@ constructor(config: ElasticsearchClientConfig, log: Logger);
| --- | --- | --- |
| config | <code>ElasticsearchClientConfig</code> | |
| log | <code>Logger</code> | |
| getAuthHeaders | <code>GetAuthHeaders</code> | |

View file

@ -9,16 +9,14 @@ Creates an instance of `ScopedClusterClient` based on the configuration the curr
<b>Signature:</b>
```typescript
asScoped(req?: {
headers?: Headers;
}): ScopedClusterClient;
asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| req | <code>{</code><br/><code> headers?: Headers;</code><br/><code> }</code> | Request the <code>ScopedClusterClient</code> instance will be scoped to. |
| request | <code>KibanaRequest &#124; LegacyRequest &#124; FakeRequest</code> | Request the <code>ScopedClusterClient</code> instance will be scoped to. Supports request optionality, Legacy.Request &amp; FakeRequest for BWC with LegacyPlatform |
<b>Returns:</b>

View file

@ -16,7 +16,7 @@ export declare class ClusterClient
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(config, log)](./kibana-plugin-server.clusterclient.(constructor).md) | | Constructs a new instance of the <code>ClusterClient</code> class |
| [(constructor)(config, log, getAuthHeaders)](./kibana-plugin-server.clusterclient.(constructor).md) | | Constructs a new instance of the <code>ClusterClient</code> class |
## Properties
@ -28,6 +28,6 @@ export declare class ClusterClient
| Method | Modifiers | Description |
| --- | --- | --- |
| [asScoped(req)](./kibana-plugin-server.clusterclient.asscoped.md) | | Creates an instance of <code>ScopedClusterClient</code> based on the configuration the current cluster client that exposes additional <code>callAsCurrentUser</code> method scoped to the provided req. Consumers shouldn't worry about closing scoped client instances, these will be automatically closed as soon as the original cluster client isn't needed anymore and closed. |
| [asScoped(request)](./kibana-plugin-server.clusterclient.asscoped.md) | | Creates an instance of <code>ScopedClusterClient</code> based on the configuration the current cluster client that exposes additional <code>callAsCurrentUser</code> method scoped to the provided req. Consumers shouldn't worry about closing scoped client instances, these will be automatically closed as soon as the original cluster client isn't needed anymore and closed. |
| [close()](./kibana-plugin-server.clusterclient.close.md) | | Closes the cluster client. After that client cannot be used and one should create a new client instance to be able to interact with Elasticsearch API. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [FakeRequest](./kibana-plugin-server.fakerequest.md) &gt; [headers](./kibana-plugin-server.fakerequest.headers.md)
## FakeRequest.headers property
Headers used for authentication against Elasticsearch
<b>Signature:</b>
```typescript
headers: Record<string, string>;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [FakeRequest](./kibana-plugin-server.fakerequest.md)
## FakeRequest interface
Fake request object created manually by Kibana plugins.
<b>Signature:</b>
```typescript
export interface FakeRequest
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [headers](./kibana-plugin-server.fakerequest.headers.md) | <code>Record&lt;string, string&gt;</code> | Headers used for authentication against Elasticsearch |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md)
## GetAuthHeaders type
Get headers to authenticate a user against Elasticsearch.
<b>Signature:</b>
```typescript
export declare type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined;
```

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `KibanaRequest` class
<b>Signature:</b>
```typescript
constructor(request: Request, params: Params, query: Query, body: Body);
constructor(request: Request, params: Params, query: Query, body: Body, withoutSecretHeaders: boolean);
```
## Parameters
@ -20,4 +20,5 @@ constructor(request: Request, params: Params, query: Query, body: Body);
| params | <code>Params</code> | |
| query | <code>Query</code> | |
| body | <code>Body</code> | |
| withoutSecretHeaders | <code>boolean</code> | |

View file

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

View file

@ -16,14 +16,13 @@ export declare class KibanaRequest<Params = unknown, Query = unknown, Body = unk
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(request, params, query, body)](./kibana-plugin-server.kibanarequest.(constructor).md) | | Constructs a new instance of the <code>KibanaRequest</code> class |
| [(constructor)(request, params, query, body, withoutSecretHeaders)](./kibana-plugin-server.kibanarequest.(constructor).md) | | Constructs a new instance of the <code>KibanaRequest</code> class |
## Properties
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [body](./kibana-plugin-server.kibanarequest.body.md) | | <code>Body</code> | |
| [headers](./kibana-plugin-server.kibanarequest.headers.md) | | <code>Headers</code> | |
| [params](./kibana-plugin-server.kibanarequest.params.md) | | <code>Params</code> | |
| [query](./kibana-plugin-server.kibanarequest.query.md) | | <code>Query</code> | |
| [route](./kibana-plugin-server.kibanarequest.route.md) | | <code>RecursiveReadonly&lt;KibanaRequestRoute&gt;</code> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [LegacyRequest](./kibana-plugin-server.legacyrequest.md)
## LegacyRequest type
Support Legacy platform request for the period of migration.
<b>Signature:</b>
```typescript
export declare type LegacyRequest = Request;
```

View file

@ -23,12 +23,14 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| Interface | Description |
| --- | --- |
| [AuthResultData](./kibana-plugin-server.authresultdata.md) | Result of an incoming request authentication. |
| [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. |
| [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. |
| [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins <code>setup</code> method. |
| [CoreStart](./kibana-plugin-server.corestart.md) | Context passed to the plugins <code>start</code> method. |
| [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. |
| [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | |
| [FakeRequest](./kibana-plugin-server.fakerequest.md) | Fake request object created manually by Kibana plugins. |
| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | |
| [HttpServiceStart](./kibana-plugin-server.httpservicestart.md) | |
| [InternalCoreStart](./kibana-plugin-server.internalcorestart.md) | |
@ -52,12 +54,14 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| --- | --- |
| [APICaller](./kibana-plugin-server.apicaller.md) | |
| [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) | |
| [AuthHeaders](./kibana-plugin-server.authheaders.md) | Auth Headers map |
| [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | |
| [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. |
| [Headers](./kibana-plugin-server.headers.md) | |
| [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | Support Legacy platform request for the period of migration. |
| [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | |
| [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | |
| [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>server</code> directory should conform to this interface. |
| [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. |
| [RecursiveReadonly](./kibana-plugin-server.recursivereadonly.md) | |
| [RouteMethod](./kibana-plugin-server.routemethod.md) | The set of common HTTP methods supported by Kibana routing. |

View file

@ -7,5 +7,5 @@
<b>Signature:</b>
```typescript
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
asScoped: (request: KibanaRequest) => SessionStorage<T>;
```

View file

@ -16,5 +16,5 @@ export interface SessionStorageFactory<T>
| Property | Type | Description |
| --- | --- | --- |
| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | <code>(request: Readonly&lt;Request&gt; &#124; KibanaRequest) =&gt; SessionStorage&lt;T&gt;</code> | |
| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | <code>(request: KibanaRequest) =&gt; SessionStorage&lt;T&gt;</code> | |

View file

@ -29,6 +29,7 @@ import { errors } from 'elasticsearch';
import { get } from 'lodash';
import { Logger } from '../logging';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { httpServerMock } from '../http/http_server.mocks';
import { ClusterClient } from './cluster_client';
const logger = loggingServiceMock.create();
@ -241,7 +242,9 @@ describe('#asScoped', () => {
});
test('creates additional Elasticsearch client only once', () => {
const firstScopedClusterClient = clusterClient.asScoped({ headers: { one: '1' } });
const firstScopedClusterClient = clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { one: '1' } })
);
expect(firstScopedClusterClient).toBeDefined();
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
@ -257,7 +260,9 @@ describe('#asScoped', () => {
jest.clearAllMocks();
const secondScopedClusterClient = clusterClient.asScoped({ headers: { two: '2' } });
const secondScopedClusterClient = clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { two: '2' } })
);
expect(secondScopedClusterClient).toBeDefined();
expect(secondScopedClusterClient).not.toBe(firstScopedClusterClient);
@ -270,7 +275,7 @@ describe('#asScoped', () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped({ headers: { one: '1' } });
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, {
@ -283,7 +288,7 @@ describe('#asScoped', () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped({ headers: { one: '1' } });
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, {
@ -296,7 +301,7 @@ describe('#asScoped', () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped({ headers: { one: '1' } });
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, {
@ -306,7 +311,9 @@ describe('#asScoped', () => {
});
test('passes only filtered headers to the scoped cluster client', () => {
clusterClient.asScoped({ headers: { zero: '0', one: '1', two: '2', three: '3' } });
clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
);
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
@ -317,7 +324,9 @@ describe('#asScoped', () => {
});
test('both scoped and internal API caller fail if cluster client is closed', async () => {
clusterClient.asScoped({ headers: { zero: '0', one: '1', two: '2', three: '3' } });
clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
);
clusterClient.close();
@ -330,6 +339,70 @@ describe('#asScoped', () => {
`"Cluster client cannot be used after it has been closed."`
);
});
test('does not fail when scope to not defined request', async () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
clusterClient.asScoped();
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{}
);
});
test('does not fail when scope to a request without headers', async () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
clusterClient.asScoped({} as any);
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{}
);
});
test('calls getAuthHeaders and filters results for a real request', async () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger, () => ({ one: '1', three: '3' }));
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { two: '2' } }));
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' }
);
});
test('getAuthHeaders results rewrite extends a request headers', async () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger, () => ({ one: 'foo' }));
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1', two: '2' } }));
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: 'foo', two: '2' }
);
});
test("doesn't call getAuthHeaders for a fake request", async () => {
const getAuthHeaders = jest.fn();
clusterClient = new ClusterClient(mockEsConfig, mockLogger, getAuthHeaders);
clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } });
expect(getAuthHeaders).not.toHaveBeenCalled();
});
test('filters a fake request headers', async () => {
clusterClient = new ClusterClient(mockEsConfig, mockLogger);
clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } });
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' }
);
});
});
describe('#close', () => {
@ -359,7 +432,7 @@ describe('#close', () => {
});
test('closes both internal and scoped underlying Elasticsearch clients', () => {
clusterClient.asScoped({ headers: { one: '1' } });
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
expect(mockEsClientInstance.close).not.toHaveBeenCalled();
expect(mockScopedEsClientInstance.close).not.toHaveBeenCalled();
@ -370,7 +443,7 @@ describe('#close', () => {
});
test('does not call close on already closed client', () => {
clusterClient.asScoped({ headers: { one: '1' } });
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
clusterClient.close();
mockEsClientInstance.close.mockClear();

View file

@ -20,7 +20,10 @@
import Boom from 'boom';
import { Client } from 'elasticsearch';
import { get } from 'lodash';
import { filterHeaders, Headers } from '../http/router';
import { Request } from 'hapi';
import { GetAuthHeaders, isRealRequest } from '../http';
import { filterHeaders, KibanaRequest, ensureRawRequest } from '../http/router';
import { Logger } from '../logging';
import {
ElasticsearchClientConfig,
@ -28,6 +31,15 @@ import {
} from './elasticsearch_client_config';
import { ScopedClusterClient } from './scoped_cluster_client';
/**
* Support Legacy platform request for the period of migration.
*
* @public
*/
export type LegacyRequest = Request;
const noop = () => undefined;
/**
* The set of options that defines how API call should be made and result be
* processed.
@ -95,6 +107,15 @@ async function callAPI(
}
}
/**
* Fake request object created manually by Kibana plugins.
* @public
*/
export interface FakeRequest {
/** Headers used for authentication against Elasticsearch */
headers: Record<string, string>;
}
/**
* Represents an Elasticsearch cluster API client and allows to call API on behalf
* of the internal Kibana user and the actual user that is derived from the request
@ -119,7 +140,11 @@ export class ClusterClient {
*/
private isClosed = false;
constructor(private readonly config: ElasticsearchClientConfig, private readonly log: Logger) {
constructor(
private readonly config: ElasticsearchClientConfig,
private readonly log: Logger,
private readonly getAuthHeaders: GetAuthHeaders = noop
) {
this.client = new Client(parseElasticsearchClientConfig(config, log));
}
@ -163,9 +188,10 @@ export class ClusterClient {
* scoped to the provided req. Consumers shouldn't worry about closing
* scoped client instances, these will be automatically closed as soon as the
* original cluster client isn't needed anymore and closed.
* @param req - Request the `ScopedClusterClient` instance will be scoped to.
* @param request - Request the `ScopedClusterClient` instance will be scoped to.
* Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform
*/
public asScoped(req: { headers?: Headers } = {}) {
public asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest) {
// It'd have been quite expensive to create and configure client for every incoming
// request since it involves parsing of the config, reading of the SSL certificate and
// key files etc. Moreover scoped client needs two Elasticsearch JS clients at the same
@ -181,11 +207,11 @@ export class ClusterClient {
);
}
const headers = req.headers
? filterHeaders(req.headers, this.config.requestHeadersWhitelist)
: req.headers;
return new ScopedClusterClient(this.callAsInternalUser, this.callAsCurrentUser, headers);
return new ScopedClusterClient(
this.callAsInternalUser,
this.callAsCurrentUser,
filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist)
);
}
/**
@ -210,4 +236,16 @@ export class ClusterClient {
throw new Error('Cluster client cannot be used after it has been closed.');
}
}
private getHeaders(
request?: KibanaRequest | LegacyRequest | FakeRequest
): Record<string, string | string[] | undefined> {
if (!isRealRequest(request)) {
return request && request.headers ? request.headers : {};
}
const authHeaders = this.getAuthHeaders(request);
const headers = ensureRawRequest(request).headers;
return { ...headers, ...authHeaders };
}
}

View file

@ -27,11 +27,15 @@ import { getEnvOptions } from '../config/__mocks__/env';
import { CoreContext } from '../core_context';
import { configServiceMock } from '../config/config_service.mock';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { ElasticsearchConfig } from './elasticsearch_config';
import { ElasticsearchService } from './elasticsearch_service';
let elasticsearchService: ElasticsearchService;
const configService = configServiceMock.create();
const deps = {
http: httpServiceMock.createSetupContract(),
};
configService.atPath.mockReturnValue(
new BehaviorSubject({
hosts: ['http://1.2.3.4'],
@ -54,7 +58,7 @@ afterEach(() => jest.clearAllMocks());
describe('#setup', () => {
test('returns legacy Elasticsearch config as a part of the contract', async () => {
const setupContract = await elasticsearchService.setup();
const setupContract = await elasticsearchService.setup(deps);
await expect(setupContract.legacy.config$.pipe(first()).toPromise()).resolves.toBeInstanceOf(
ElasticsearchConfig
@ -68,7 +72,7 @@ describe('#setup', () => {
() => mockAdminClusterClientInstance
).mockImplementationOnce(() => mockDataClusterClientInstance);
const setupContract = await elasticsearchService.setup();
const setupContract = await elasticsearchService.setup(deps);
const [esConfig, adminClient, dataClient] = await combineLatest(
setupContract.legacy.config$,
@ -85,12 +89,14 @@ describe('#setup', () => {
expect(MockClusterClient).toHaveBeenNthCalledWith(
1,
esConfig,
expect.objectContaining({ context: ['elasticsearch', 'admin'] })
expect.objectContaining({ context: ['elasticsearch', 'admin'] }),
undefined
);
expect(MockClusterClient).toHaveBeenNthCalledWith(
2,
esConfig,
expect.objectContaining({ context: ['elasticsearch', 'data'] })
expect.objectContaining({ context: ['elasticsearch', 'data'] }),
expect.any(Function)
);
expect(mockAdminClusterClientInstance.close).not.toHaveBeenCalled();
@ -98,7 +104,7 @@ describe('#setup', () => {
});
test('returns `createClient` as a part of the contract', async () => {
const setupContract = await elasticsearchService.setup();
const setupContract = await elasticsearchService.setup(deps);
const mockClusterClientInstance = { close: jest.fn() };
MockClusterClient.mockImplementation(() => mockClusterClientInstance);
@ -110,7 +116,8 @@ describe('#setup', () => {
expect(MockClusterClient).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] })
expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }),
expect.any(Function)
);
});
});
@ -123,7 +130,7 @@ describe('#stop', () => {
() => mockAdminClusterClientInstance
).mockImplementationOnce(() => mockDataClusterClientInstance);
await elasticsearchService.setup();
await elasticsearchService.setup(deps);
await elasticsearchService.stop();
expect(mockAdminClusterClientInstance.close).toHaveBeenCalledTimes(1);

View file

@ -25,6 +25,7 @@ import { Logger } from '../logging';
import { ClusterClient } from './cluster_client';
import { ElasticsearchClientConfig } from './elasticsearch_client_config';
import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config';
import { HttpServiceSetup, GetAuthHeaders } from '../http/';
/** @internal */
interface CoreClusterClients {
@ -33,6 +34,10 @@ interface CoreClusterClients {
dataClient: ClusterClient;
}
interface SetupDeps {
http: HttpServiceSetup;
}
/** @public */
export interface ElasticsearchServiceSetup {
// Required for the BWC with the legacy Kibana only.
@ -58,7 +63,7 @@ export class ElasticsearchService implements CoreService<ElasticsearchServiceSet
.pipe(map(rawConfig => new ElasticsearchConfig(rawConfig)));
}
public async setup(): Promise<ElasticsearchServiceSetup> {
public async setup(deps: SetupDeps): Promise<ElasticsearchServiceSetup> {
this.log.debug('Setting up elasticsearch service');
const clients$ = this.config$.pipe(
@ -78,7 +83,7 @@ export class ElasticsearchService implements CoreService<ElasticsearchServiceSet
const coreClients = {
config,
adminClient: this.createClusterClient('admin', config),
dataClient: this.createClusterClient('data', config),
dataClient: this.createClusterClient('data', config, deps.http.auth.getAuthHeaders),
};
subscriber.next(coreClients);
@ -103,7 +108,7 @@ export class ElasticsearchService implements CoreService<ElasticsearchServiceSet
dataClient$: clients$.pipe(map(clients => clients.dataClient)),
createClient: (type: string, clientConfig: ElasticsearchClientConfig) => {
return this.createClusterClient(type, clientConfig);
return this.createClusterClient(type, clientConfig, deps.http.auth.getAuthHeaders);
},
};
}
@ -119,7 +124,15 @@ export class ElasticsearchService implements CoreService<ElasticsearchServiceSet
}
}
private createClusterClient(type: string, config: ElasticsearchClientConfig) {
return new ClusterClient(config, this.coreContext.logger.get('elasticsearch', type));
private createClusterClient(
type: string,
config: ElasticsearchClientConfig,
getAuthHeaders?: GetAuthHeaders
) {
return new ClusterClient(
config,
this.coreContext.logger.get('elasticsearch', type),
getAuthHeaders
);
}
}

View file

@ -18,7 +18,7 @@
*/
export { ElasticsearchServiceSetup, ElasticsearchService } from './elasticsearch_service';
export { CallAPIOptions, ClusterClient } from './cluster_client';
export { CallAPIOptions, ClusterClient, FakeRequest, LegacyRequest } from './cluster_client';
export { ScopedClusterClient, Headers, APICaller } from './scoped_cluster_client';
export { ElasticsearchClientConfig } from './elasticsearch_client_config';
export { config } from './elasticsearch_config';

View file

@ -0,0 +1,49 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { AuthHeadersStorage } from './auth_headers_storage';
import { KibanaRequest } from './router';
import { httpServerMock } from './http_server.mocks';
describe('AuthHeadersStorage', () => {
describe('stores authorization headers', () => {
it('retrieves a copy of headers associated with Kibana request', () => {
const headers = { authorization: 'token' };
const storage = new AuthHeadersStorage();
const rawRequest = httpServerMock.createRawRequest();
storage.set(KibanaRequest.from(rawRequest), headers);
expect(storage.get(KibanaRequest.from(rawRequest))).toEqual(headers);
});
it('retrieves a copy of headers associated with Legacy.Request', () => {
const headers = { authorization: 'token' };
const storage = new AuthHeadersStorage();
const rawRequest = httpServerMock.createRawRequest();
storage.set(rawRequest, headers);
expect(storage.get(rawRequest)).toEqual(headers);
});
it('retrieves a copy of headers associated with both KibanaRequest & Legacy.Request', () => {
const headers = { authorization: 'token' };
const storage = new AuthHeadersStorage();
const rawRequest = httpServerMock.createRawRequest();
storage.set(KibanaRequest.from(rawRequest), headers);
expect(storage.get(rawRequest)).toEqual(headers);
});
});
});

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Request } from 'hapi';
import { KibanaRequest, getIncomingMessage } from './router';
import { AuthHeaders } from './lifecycle/auth';
/**
* Get headers to authenticate a user against Elasticsearch.
* @public
* */
export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined;
export class AuthHeadersStorage {
private authHeadersCache = new WeakMap<ReturnType<typeof getIncomingMessage>, AuthHeaders>();
public set = (request: KibanaRequest | Request, headers: AuthHeaders) => {
this.authHeadersCache.set(getIncomingMessage(request), headers);
};
public get: GetAuthHeaders = request => {
return this.authHeadersCache.get(getIncomingMessage(request));
};
}

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Request } from 'hapi';
import { KibanaRequest, toRawRequest } from './router';
import { KibanaRequest, getIncomingMessage } from './router';
export enum AuthStatus {
authenticated = 'authenticated',
@ -25,9 +25,6 @@ export enum AuthStatus {
unknown = 'unknown',
}
const getIncomingMessage = (request: KibanaRequest | Request) =>
request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req;
export class AuthStateStorage {
private readonly storage = new WeakMap<ReturnType<typeof getIncomingMessage>, unknown>();
constructor(private readonly canBeAuthenticated: () => boolean) {}

View file

@ -17,13 +17,10 @@
* under the License.
*/
import { Request } from 'hapi';
import { KibanaRequest, toRawRequest } from './router';
import { KibanaRequest, getIncomingMessage } from './router';
import { modifyUrl } from '../../utils';
const getIncomingMessage = (request: KibanaRequest | Request) =>
request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req;
export class BasePath {
private readonly basePathCache = new WeakMap<ReturnType<typeof getIncomingMessage>, string>();

View file

@ -20,7 +20,7 @@
import { Request, Server } from 'hapi';
import hapiAuthCookie from 'hapi-auth-cookie';
import { KibanaRequest, toRawRequest } from './router';
import { KibanaRequest, ensureRawRequest } from './router';
import { SessionStorageFactory, SessionStorage } from './session_storage';
export interface SessionStorageCookieOptions<T> {
@ -31,7 +31,7 @@ export interface SessionStorageCookieOptions<T> {
}
class ScopedCookieSessionStorage<T extends Record<string, any>> implements SessionStorage<T> {
constructor(private readonly server: Server, private readonly request: Readonly<Request>) {}
constructor(private readonly server: Server, private readonly request: Request) {}
public async get(): Promise<T | null> {
try {
return await this.server.auth.test('security-cookie', this.request as Request);
@ -73,9 +73,8 @@ export async function createCookieSessionStorageFactory<T>(
});
return {
asScoped(request: Readonly<Request> | KibanaRequest) {
const req = request instanceof KibanaRequest ? toRawRequest(request) : request;
return new ScopedCookieSessionStorage<T>(server, req);
asScoped(request: KibanaRequest) {
return new ScopedCookieSessionStorage<T>(server, ensureRawRequest(request));
},
};
}

View file

@ -16,11 +16,34 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Server } from 'hapi';
import request from 'request';
import supertest from 'supertest';
import { ByteSizeValue } from '@kbn/config-schema';
import { HttpServer } from './http_server';
import { HttpConfig } from './http_config';
import { Router } from './router';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { createCookieSessionStorageFactory } from './cookie_session_storage';
let server: HttpServer;
const logger = loggingServiceMock.create();
const config = {
host: '127.0.0.1',
maxPayload: new ByteSizeValue(1024),
ssl: {},
} as HttpConfig;
beforeEach(() => {
server = new HttpServer(logger.get());
});
afterEach(async () => {
await server.stop();
});
interface User {
id: string;
roles?: string[];
@ -53,24 +76,25 @@ const cookieOptions = {
describe('Cookie based SessionStorage', () => {
describe('#set()', () => {
it('Should write to session storage & set cookies', async () => {
const server = new Server();
const factory = await createCookieSessionStorageFactory(server, cookieOptions);
server.route({
method: 'GET',
path: '/set',
options: {
handler: (req, h) => {
const sessionStorage = factory.asScoped(req);
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return h.response();
},
},
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => {
const sessionStorage = factory.asScoped(req);
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return res.ok({});
});
const response = await server.inject('/set');
expect(response.statusCode).toBe(200);
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
const cookies = response.headers['set-cookie'];
const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200);
const cookies = response.get('set-cookie');
expect(cookies).toBeDefined();
expect(cookies).toHaveLength(1);
@ -84,100 +108,98 @@ describe('Cookie based SessionStorage', () => {
});
describe('#get()', () => {
it('Should read from session storage', async () => {
const server = new Server();
const factory = await createCookieSessionStorageFactory(server, cookieOptions);
server.route({
method: 'GET',
path: '/get',
options: {
handler: async (req, h) => {
const sessionStorage = factory.asScoped(req);
const sessionValue = await sessionStorage.get();
if (!sessionValue) {
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return h.response();
}
return h.response(sessionValue.value);
},
},
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => {
const sessionStorage = factory.asScoped(req);
const sessionValue = await sessionStorage.get();
if (!sessionValue) {
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return res.ok({});
}
return res.ok({ value: sessionValue.value });
});
const response = await server.inject('/get');
expect(response.statusCode).toBe(200);
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
const cookies = response.headers['set-cookie'];
const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200);
const cookies = response.get('set-cookie');
expect(cookies).toBeDefined();
expect(cookies).toHaveLength(1);
const sessionCookie = retrieveSessionCookie(cookies[0]);
const response2 = await server.inject({
method: 'GET',
url: '/get',
headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` },
});
expect(response2.statusCode).toBe(200);
expect(response2.result).toEqual(userData);
await supertest(innerServer.listener)
.get('/')
.set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`)
.expect(200, { value: userData });
});
it('Should return null for empty session', async () => {
const server = new Server();
const factory = await createCookieSessionStorageFactory(server, cookieOptions);
server.route({
method: 'GET',
path: '/get-empty',
options: {
handler: async (req, h) => {
const sessionStorage = factory.asScoped(req);
const sessionValue = await sessionStorage.get();
return h.response(JSON.stringify(sessionValue));
},
},
});
const response = await server.inject('/get-empty');
expect(response.statusCode).toBe(200);
expect(response.result).toBe('null');
const router = new Router('');
const cookies = response.headers['set-cookie'];
router.get({ path: '/', validate: false }, async (req, res) => {
const sessionStorage = factory.asScoped(req);
const sessionValue = await sessionStorage.get();
return res.ok({ value: sessionValue });
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200, { value: null });
const cookies = response.get('set-cookie');
expect(cookies).not.toBeDefined();
});
it('Should return null for invalid session & clean cookies', async () => {
const server = new Server();
const factory = await createCookieSessionStorageFactory(server, cookieOptions);
let setOnce = false;
server.route({
method: 'GET',
path: '/get-invalid',
options: {
handler: async (req, h) => {
const sessionStorage = factory.asScoped(req);
if (!setOnce) {
setOnce = true;
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return h.response();
}
const sessionValue = await sessionStorage.get();
return h.response(JSON.stringify(sessionValue));
},
},
});
const response = await server.inject('/get-invalid');
expect(response.statusCode).toBe(200);
const router = new Router('');
const cookies = response.headers['set-cookie'];
let setOnce = false;
router.get({ path: '/', validate: false }, async (req, res) => {
const sessionStorage = factory.asScoped(req);
if (!setOnce) {
setOnce = true;
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return res.ok({ value: userData });
}
const sessionValue = await sessionStorage.get();
return res.ok({ value: sessionValue });
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200, { value: userData });
const cookies = response.get('set-cookie');
expect(cookies).toBeDefined();
await delay(sessionDurationMs);
const sessionCookie = retrieveSessionCookie(cookies[0]);
const response2 = await server.inject({
method: 'GET',
url: '/get-invalid',
headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` },
});
expect(response2.statusCode).toBe(200);
expect(response2.result).toBe('null');
const response2 = await supertest(innerServer.listener)
.get('/')
.set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`)
.expect(200, { value: null });
const cookies2 = response2.headers['set-cookie'];
const cookies2 = response2.get('set-cookie');
expect(cookies2).toEqual([
'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/',
]);
@ -185,36 +207,37 @@ describe('Cookie based SessionStorage', () => {
});
describe('#clear()', () => {
it('Should clear session storage & remove cookies', async () => {
const server = new Server();
const factory = await createCookieSessionStorageFactory(server, cookieOptions);
server.route({
method: 'GET',
path: '/clear',
options: {
handler: async (req, h) => {
const sessionStorage = factory.asScoped(req);
if (await sessionStorage.get()) {
sessionStorage.clear();
return h.response();
}
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return h.response();
},
},
});
const response = await server.inject('/clear');
const cookies = response.headers['set-cookie'];
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => {
const sessionStorage = factory.asScoped(req);
if (await sessionStorage.get()) {
sessionStorage.clear();
return res.ok({});
}
sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs });
return res.ok({});
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200);
const cookies = response.get('set-cookie');
const sessionCookie = retrieveSessionCookie(cookies[0]);
const response2 = await server.inject({
method: 'GET',
url: '/clear',
headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` },
});
expect(response2.statusCode).toBe(200);
const response2 = await supertest(innerServer.listener)
.get('/')
.set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`)
.expect(200);
const cookies2 = response2.headers['set-cookie'];
const cookies2 = response2.get('set-cookie');
expect(cookies2).toEqual([
'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/',
]);

View file

@ -638,7 +638,7 @@ describe('#registerAuth', () => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return t.authenticated(user);
return t.authenticated({ state: user });
}, cookieOptions);
registerRouter(router);
await server.start();
@ -714,7 +714,7 @@ describe('#registerAuth', () => {
});
});
it(`allows manipulating cookies from route handler`, async () => {
it('allows manipulating cookies from route handler', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const { sessionStorageFactory } = await registerAuth<StorageData>((req, t) => {
const user = { id: '42' };
@ -748,6 +748,55 @@ describe('#registerAuth', () => {
'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/',
]);
});
it('is the only place with access to the authorization header', async () => {
const token = 'Basic: user:password';
const {
registerAuth,
registerOnPreAuth,
registerOnPostAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
let fromRegisterOnPreAuth;
await registerOnPreAuth((req, t) => {
fromRegisterOnPreAuth = req.getFilteredHeaders(['authorization']);
return t.next();
});
let fromRegisterAuth;
await registerAuth((req, t) => {
fromRegisterAuth = req.getFilteredHeaders(['authorization']);
return t.authenticated();
}, cookieOptions);
let fromRegisterOnPostAuth;
await registerOnPostAuth((req, t) => {
fromRegisterOnPostAuth = req.getFilteredHeaders(['authorization']);
return t.next();
});
let fromRouteHandler;
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => {
fromRouteHandler = req.getFilteredHeaders(['authorization']);
return res.ok({ content: 'ok' });
});
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.set('Authorization', token)
.expect(200);
expect(fromRegisterOnPreAuth).toEqual({});
expect(fromRegisterAuth).toEqual({ authorization: token });
expect(fromRegisterOnPostAuth).toEqual({});
expect(fromRouteHandler).toEqual({});
});
});
test('enables auth for a route by default if registerAuth has been called', async () => {
@ -908,7 +957,7 @@ describe('#auth.get()', () => {
const { registerRouter, registerAuth, server: innerServer, auth } = await server.setup(config);
const { sessionStorageFactory } = await registerAuth<StorageData>((req, t) => {
sessionStorageFactory.asScoped(req).set({ value: user, expires: Date.now() + 1000 });
return t.authenticated(user);
return t.authenticated({ state: user });
}, cookieOptions);
const router = new Router('');

View file

@ -32,6 +32,7 @@ import {
} from './cookie_session_storage';
import { SessionStorageFactory } from './session_storage';
import { AuthStateStorage } from './auth_state_storage';
import { AuthHeadersStorage } from './auth_headers_storage';
import { BasePath } from './base_path_service';
export interface HttpServerSetup {
@ -73,6 +74,7 @@ export interface HttpServerSetup {
auth: {
get: AuthStateStorage['get'];
isAuthenticated: AuthStateStorage['isAuthenticated'];
getAuthHeaders: AuthHeadersStorage['get'];
};
}
@ -83,9 +85,11 @@ export class HttpServer {
private authRegistered = false;
private readonly authState: AuthStateStorage;
private readonly authHeaders: AuthHeadersStorage;
constructor(private readonly log: Logger) {
this.authState = new AuthStateStorage(() => this.authRegistered);
this.authHeaders = new AuthHeadersStorage();
}
public isListening() {
@ -120,6 +124,7 @@ export class HttpServer {
auth: {
get: this.authState.get,
isAuthenticated: this.authState.isAuthenticated,
getAuthHeaders: this.authHeaders.get,
},
// Return server instance with the connection options so that we can properly
// bridge core and the "legacy" Kibana internally. Once this bridge isn't
@ -223,7 +228,10 @@ export class HttpServer {
);
this.server.auth.scheme('login', () => ({
authenticate: adoptToHapiAuthFormat(fn, this.authState.set),
authenticate: adoptToHapiAuthFormat(fn, (req, { state, headers }) => {
this.authState.set(req, state);
this.authHeaders.set(req, headers);
}),
}));
this.server.auth.strategy('session', 'login');

View file

@ -43,6 +43,7 @@ const createSetupContractMock = () => {
auth: {
get: jest.fn(),
isAuthenticated: jest.fn(),
getAuthHeaders: jest.fn(),
},
createNewServer: jest.fn(),
};

View file

@ -19,7 +19,9 @@
export { config, HttpConfig, HttpConfigType } from './http_config';
export { HttpService, HttpServiceSetup, HttpServiceStart } from './http_service';
export { GetAuthHeaders } from './auth_headers_storage';
export {
isRealRequest,
KibanaRequest,
KibanaRequestRoute,
Router,
@ -28,6 +30,6 @@ export {
} from './router';
export { BasePathProxyServer } from './base_path_proxy_server';
export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
export { AuthenticationHandler, AuthToolkit } from './lifecycle/auth';
export { AuthenticationHandler, AuthHeaders, AuthResultData, AuthToolkit } from './lifecycle/auth';
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
export { SessionStorageFactory, SessionStorage } from './session_storage';

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const clusterClientMock = jest.fn();
jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({
ScopedClusterClient: clusterClientMock,
}));

View file

@ -17,6 +17,9 @@
* under the License.
*/
import Boom from 'boom';
import { Request } from 'hapi';
import { first } from 'rxjs/operators';
import { clusterClientMock } from './http_service.test.mocks';
import { Router } from '../router';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
@ -48,16 +51,20 @@ describe('http service', () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
afterEach(async () => {
clusterClientMock.mockClear();
await root.shutdown();
});
it('Should run auth for legacy routes and proxy request to legacy server route handlers', async () => {
it('runs auth for legacy routes and proxy request to legacy server route handlers', async () => {
const { http } = await root.setup();
const { sessionStorageFactory } = await http.registerAuth<StorageData>((req, t) => {
if (req.headers.authorization) {
const headers = req.getFilteredHeaders(['authorization']);
if (headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated(user);
return t.authenticated({ state: user });
} else {
return t.rejected(Boom.unauthorized());
}
@ -79,15 +86,54 @@ describe('http service', () => {
expect(response.header['set-cookie']).toBe(undefined);
});
it('Should pass associated auth state to Legacy platform', async () => {
it('passes authHeaders as request headers to the legacy platform', async () => {
const token = 'Basic: name:password';
const { http } = await root.setup();
const { sessionStorageFactory } = await http.registerAuth<StorageData>((req, t) => {
const headers = req.getFilteredHeaders(['authorization']);
if (headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated({
state: user,
headers: {
authorization: token,
},
});
} else {
return t.rejected(Boom.unauthorized());
}
}, cookieOptions);
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: (req: Request) => ({
authorization: req.headers.authorization,
custom: req.headers.custom,
}),
});
await kbnTestServer.request
.get(root, legacyUrl)
.set({ custom: 'custom-header' })
.expect(200, { authorization: token, custom: 'custom-header' });
});
it('passes associated auth state to Legacy platform', async () => {
const user = { id: '42' };
const { http } = await root.setup();
const { sessionStorageFactory } = await http.registerAuth<StorageData>((req, t) => {
if (req.headers.authorization) {
const headers = req.getFilteredHeaders(['authorization']);
if (headers.authorization) {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated(user);
return t.authenticated({ state: user });
} else {
return t.rejected(Boom.unauthorized());
}
@ -108,6 +154,60 @@ describe('http service', () => {
expect(response.header['set-cookie']).toBe(undefined);
});
it('rewrites authorization header via authHeaders to make a request to Elasticsearch', async () => {
const authHeaders = { authorization: 'Basic: user:password' };
const { http, elasticsearch } = await root.setup();
const { registerAuth, registerRouter } = http;
await registerAuth((req, t) => {
return t.authenticated({ headers: authHeaders });
}, cookieOptions);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/').expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual(authHeaders);
});
it('pass request authorization header to Elasticsearch if registerAuth was not set', async () => {
const authorizationHeader = 'Basic: username:password';
const { http, elasticsearch } = await root.setup();
const { registerRouter } = http;
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request
.get(root, '/')
.set('Authorization', authorizationHeader)
.expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual({
authorization: authorizationHeader,
});
});
});
describe('#registerOnPostAuth()', () => {
@ -117,7 +217,7 @@ describe('http service', () => {
}, 30000);
afterEach(async () => await root.shutdown());
it('Should support passing request through to the route handler', async () => {
it('supports passing request through to the route handler', async () => {
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
@ -133,7 +233,7 @@ describe('http service', () => {
await kbnTestServer.request.get(root, '/').expect(200, { content: 'ok' });
});
it('Should support redirecting to configured url', async () => {
it('supports redirecting to configured url', async () => {
const redirectTo = '/redirect-url';
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => t.redirected(redirectTo));
@ -143,7 +243,7 @@ describe('http service', () => {
expect(response.header.location).toBe(redirectTo);
});
it('Should failing a request with configured error and status code', async () => {
it('fails a request with configured error and status code', async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) =>
t.rejected(new Error('unexpected error'), { statusCode: 400 })
@ -155,7 +255,7 @@ describe('http service', () => {
.expect(400, { statusCode: 400, error: 'Bad Request', message: 'unexpected error' });
});
it(`Shouldn't expose internal error details`, async () => {
it(`doesn't expose internal error details`, async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => {
throw new Error('sensitive info');
@ -169,7 +269,7 @@ describe('http service', () => {
});
});
it(`Shouldn't share request object between interceptors`, async () => {
it(`doesn't share request object between interceptors`, async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => {
// @ts-ignore. don't complain customField is not defined on Request type

View file

@ -22,10 +22,14 @@ import { adoptToHapiAuthFormat } from './auth';
import { httpServerMock } from '../http_server.mocks';
describe('adoptToHapiAuthFormat', () => {
it('Should allow authenticating a user identity with given credentials', async () => {
const credentials = {};
it('allows to associate arbitrary data with an incoming request', async () => {
const authData = {
state: { foo: 'bar' },
headers: { authorization: 'baz' },
};
const authenticatedMock = jest.fn();
const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(credentials));
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(authData), onSuccessMock);
await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
@ -34,12 +38,17 @@ describe('adoptToHapiAuthFormat', () => {
);
expect(authenticatedMock).toBeCalledTimes(1);
expect(authenticatedMock).toBeCalledWith({ credentials });
expect(authenticatedMock).toBeCalledWith({ credentials: authData.state });
expect(onSuccessMock).toBeCalledTimes(1);
const [[, onSuccessData]] = onSuccessMock.mock.calls;
expect(onSuccessData).toEqual(authData);
});
it('Should allow redirecting to specified url', async () => {
const redirectUrl = '/docs';
const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl));
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl), onSuccessMock);
const takeoverSymbol = {};
const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol }));
const result = await onAuth(
@ -51,11 +60,14 @@ describe('adoptToHapiAuthFormat', () => {
expect(redirectMock).toBeCalledWith(redirectUrl);
expect(result).toBe(takeoverSymbol);
expect(onSuccessMock).not.toHaveBeenCalled();
});
it('Should allow to specify statusCode and message for Boom error', async () => {
const onAuth = adoptToHapiAuthFormat((req, t) =>
t.rejected(new Error('not found'), { statusCode: 404 })
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat(
(req, t) => t.rejected(new Error('not found'), { statusCode: 404 }),
onSuccessMock
);
const result = (await onAuth(
httpServerMock.createRawRequest(),
@ -65,6 +77,7 @@ describe('adoptToHapiAuthFormat', () => {
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('not found');
expect(result.output.statusCode).toBe(404);
expect(onSuccessMock).not.toHaveBeenCalled();
});
it('Should return Boom.internal error error if interceptor throws', async () => {

View file

@ -19,6 +19,7 @@
import Boom from 'boom';
import { noop } from 'lodash';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { KibanaRequest } from '../router';
enum ResultType {
authenticated = 'authenticated',
@ -26,9 +27,8 @@ enum ResultType {
rejected = 'rejected',
}
interface Authenticated {
interface Authenticated extends AuthResultData {
type: ResultType.authenticated;
state: object;
}
interface Redirected {
@ -45,8 +45,12 @@ interface Rejected {
type AuthResult = Authenticated | Rejected | Redirected;
const authResult = {
authenticated(state: object = {}): AuthResult {
return { type: ResultType.authenticated, state };
authenticated(data: Partial<AuthResultData> = {}): AuthResult {
return {
type: ResultType.authenticated,
state: data.state || {},
headers: data.headers || {},
};
},
redirected(url: string): AuthResult {
return { type: ResultType.redirected, url };
@ -73,13 +77,35 @@ const authResult = {
},
};
/**
* Auth Headers map
* @public
* */
export type AuthHeaders = Record<string, string>;
/**
* Result of an incoming request authentication.
* @public
* */
export interface AuthResultData {
/**
* Data to associate with an incoming request. Any downstream plugin may get access to the data.
*/
state: Record<string, unknown>;
/**
* Auth specific headers to authenticate a user against Elasticsearch.
*/
headers: AuthHeaders;
}
/**
* @public
* A tool set defining an outcome of Auth interceptor for incoming request.
*/
export interface AuthToolkit {
/** Authentication is successful with given credentials, allow request to pass through */
authenticated: (state?: object) => AuthResult;
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
/** Authentication requires to interrupt request handling and redirect to a configured url */
redirected: (url: string) => AuthResult;
/** Authentication is unsuccessful, fail the request with specified error. */
@ -94,29 +120,29 @@ const toolkit: AuthToolkit = {
/** @public */
export type AuthenticationHandler = (
request: Readonly<Request>,
request: KibanaRequest,
t: AuthToolkit
) => AuthResult | Promise<AuthResult>;
/** @public */
export function adoptToHapiAuthFormat(
fn: AuthenticationHandler,
onSuccess: (req: Request, state: unknown) => void = noop
onSuccess: (req: Request, data: AuthResultData) => void = noop
) {
return async function interceptAuth(
req: Request,
h: ResponseToolkit
): Promise<Lifecycle.ReturnValue> {
try {
const result = await fn(req, toolkit);
const result = await fn(KibanaRequest.from(req, undefined, false), toolkit);
if (!authResult.isValid(result)) {
throw new Error(
`Unexpected result from Authenticate. Expected AuthResult, but given: ${result}.`
);
}
if (authResult.isAuthenticated(result)) {
onSuccess(req, result.state);
return h.authenticated({ credentials: result.state });
onSuccess(req, { state: result.state, headers: result.headers });
return h.authenticated({ credentials: result.state || {} });
}
if (authResult.isRedirected(result)) {
return h.redirect(result.url).takeover();

View file

@ -24,9 +24,16 @@ export type Headers = Record<string, string | string[] | undefined>;
const normalizeHeaderField = (field: string) => field.trim().toLowerCase();
export function filterHeaders(headers: Headers, fieldsToKeep: string[]) {
export function filterHeaders(
headers: Headers,
fieldsToKeep: string[],
fieldsToExclude: string[] = []
) {
const fieldsToExcludeNormalized = fieldsToExclude.map(normalizeHeaderField);
// Normalize list of headers we want to allow in upstream request
const fieldsToKeepNormalized = fieldsToKeep.map(normalizeHeaderField);
const fieldsToKeepNormalized = fieldsToKeep
.map(normalizeHeaderField)
.filter(name => !fieldsToExcludeNormalized.includes(name));
return pick(headers, fieldsToKeepNormalized);
}

View file

@ -19,5 +19,11 @@
export { Headers, filterHeaders } from './headers';
export { Router } from './router';
export { KibanaRequest, KibanaRequestRoute, toRawRequest } from './request';
export {
KibanaRequest,
KibanaRequestRoute,
ensureRawRequest,
isRealRequest,
getIncomingMessage,
} from './request';
export { RouteMethod, RouteConfigOptions } from './route';

View file

@ -0,0 +1,73 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { KibanaRequest } from './request';
import { httpServerMock } from '../http_server.mocks';
describe('KibanaRequest', () => {
describe('#getFilteredHeaders', () => {
it('returns request headers', () => {
const request = httpServerMock.createRawRequest({
headers: { custom: 'one' },
});
const kibanaRequest = KibanaRequest.from(request);
expect(kibanaRequest.getFilteredHeaders(['custom'])).toEqual({
custom: 'one',
});
});
it('normalizes a header name', () => {
const request = httpServerMock.createRawRequest({
headers: { custom: 'one' },
});
const kibanaRequest = KibanaRequest.from(request);
expect(kibanaRequest.getFilteredHeaders(['CUSTOM'])).toEqual({
custom: 'one',
});
});
it('returns an empty object is no headers were specified', () => {
const request = httpServerMock.createRawRequest({
headers: { custom: 'one' },
});
const kibanaRequest = KibanaRequest.from(request);
expect(kibanaRequest.getFilteredHeaders([])).toEqual({});
});
it("doesn't expose authorization header by default", () => {
const request = httpServerMock.createRawRequest({
headers: { custom: 'one', authorization: 'token' },
});
const kibanaRequest = KibanaRequest.from(request);
expect(kibanaRequest.getFilteredHeaders(['custom', 'authorization'])).toEqual({
custom: 'one',
});
});
it('exposes authorization header if secured = false', () => {
const request = httpServerMock.createRawRequest({
headers: { custom: 'one', authorization: 'token' },
});
const kibanaRequest = KibanaRequest.from(request, undefined, false);
expect(kibanaRequest.getFilteredHeaders(['custom', 'authorization'])).toEqual({
custom: 'one',
authorization: 'token',
});
});
});
});

View file

@ -18,11 +18,12 @@
*/
import { Url } from 'url';
import { IncomingMessage } from 'http';
import { ObjectType, TypeOf } from '@kbn/config-schema';
import { Request } from 'hapi';
import { deepFreeze, RecursiveReadonly } from '../../../utils';
import { filterHeaders, Headers } from './headers';
import { filterHeaders } from './headers';
import { RouteMethod, RouteSchemas, RouteConfigOptions } from './route';
const requestSymbol = Symbol('request');
@ -37,6 +38,7 @@ export interface KibanaRequestRoute {
options: Required<RouteConfigOptions>;
}
const secretHeaders = ['authorization'];
/**
* Kibana specific abstraction for an incoming request.
* @public
@ -49,10 +51,17 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
*/
public static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(
req: Request,
routeSchemas?: RouteSchemas<P, Q, B>
routeSchemas?: RouteSchemas<P, Q, B>,
withoutSecretHeaders: boolean = true
) {
const requestParts = KibanaRequest.validate(req, routeSchemas);
return new KibanaRequest(req, requestParts.params, requestParts.query, requestParts.body);
return new KibanaRequest(
req,
requestParts.params,
requestParts.query,
requestParts.body,
withoutSecretHeaders
);
}
/**
@ -86,7 +95,6 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
return { query, params, body };
}
public readonly headers: Headers;
public readonly url: Url;
public readonly route: RecursiveReadonly<KibanaRequestRoute>;
@ -97,17 +105,26 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
request: Request,
readonly params: Params,
readonly query: Query,
readonly body: Body
readonly body: Body,
private readonly withoutSecretHeaders: boolean
) {
this.headers = request.headers;
this.url = request.url;
this[requestSymbol] = request;
// prevent Symbol exposure via Object.getOwnPropertySymbols()
Object.defineProperty(this, requestSymbol, {
value: request,
enumerable: false,
});
this.route = deepFreeze(this.getRouteInfo());
}
public getFilteredHeaders(headersToKeep: string[]) {
return filterHeaders(this.headers, headersToKeep);
return filterHeaders(
this[requestSymbol].headers,
headersToKeep,
this.withoutSecretHeaders ? secretHeaders : []
);
}
private getRouteInfo() {
@ -124,7 +141,38 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
}
/**
* Returns underlying Hapi Request object for KibanaRequest
* Returns underlying Hapi Request
* @internal
*/
export const toRawRequest = (request: KibanaRequest) => request[requestSymbol];
export const ensureRawRequest = (request: KibanaRequest | Request) =>
isKibanaRequest(request) ? request[requestSymbol] : request;
/**
* Returns http.IncomingMessage that is used an identifier for New Platform KibanaRequest
* and Legacy platform Hapi Request.
* Exposed while New platform supports Legacy Platform.
* @internal
*/
export const getIncomingMessage = (request: KibanaRequest | Request): IncomingMessage => {
return ensureRawRequest(request).raw.req;
};
function isKibanaRequest(request: unknown): request is KibanaRequest {
return request instanceof KibanaRequest;
}
function isRequest(request: any): request is Request {
try {
return request.raw.req && typeof request.raw.req === 'object';
} catch {
return false;
}
}
/**
* Checks if an incoming request either KibanaRequest or Legacy.Request
* @internal
*/
export function isRealRequest(request: unknown): request is KibanaRequest | Request {
return isKibanaRequest(request) || isRequest(request);
}

View file

@ -17,7 +17,6 @@
* under the License.
*/
import { Request } from 'hapi';
import { KibanaRequest } from './router';
/**
* Provides an interface to store and retrieve data across requests.
@ -43,5 +42,5 @@ export interface SessionStorage<T> {
* SessionStorage factory to bind one to an incoming request
* @public */
export interface SessionStorageFactory<T> {
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
asScoped: (request: KibanaRequest) => SessionStorage<T>;
}

View file

@ -49,10 +49,15 @@ export {
ScopedClusterClient,
ElasticsearchClientConfig,
APICaller,
FakeRequest,
LegacyRequest,
} from './elasticsearch';
export {
AuthenticationHandler,
AuthHeaders,
AuthResultData,
AuthToolkit,
GetAuthHeaders,
KibanaRequest,
KibanaRequestRoute,
OnPreAuthHandler,

View file

@ -75,6 +75,9 @@ beforeEach(() => {
http: {
options: { someOption: 'foo', someAnotherOption: 'bar' },
server: { listener: { addListener: jest.fn() }, route: jest.fn() },
auth: {
getAuthHeaders: () => undefined,
},
},
plugins: {
contracts: new Map([['plugin-id', 'plugin-value']]),

View file

@ -190,6 +190,11 @@ export class LegacyService implements CoreService {
}
private setupProxyListener(server: HapiServer) {
const { setupDeps } = this;
if (!setupDeps) {
throw new Error('Legacy service is not setup yet.');
}
const legacyProxy = new LegacyPlatformProxy(
this.coreContext.logger.get('legacy-proxy'),
server.listener
@ -211,7 +216,13 @@ export class LegacyService implements CoreService {
maxBytes: Number.MAX_SAFE_INTEGER,
},
},
handler: async ({ raw: { req, res } }, responseToolkit) => {
handler: async (request, responseToolkit) => {
const { req, res } = request.raw;
const authHeaders = setupDeps.core.http.auth.getAuthHeaders(request);
if (authHeaders) {
// some plugins in Legacy relay on headers.authorization presence
req.headers = Object.assign(req.headers, authHeaders);
}
if (this.kbnServer === undefined) {
this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url}.`);

View file

@ -25,11 +25,20 @@ export type APICaller = (endpoint: string, clientParams: Record<string, unknown>
// Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type AuthenticationHandler = (request: Readonly<Request>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
// @public
export type AuthHeaders = Record<string, string>;
// @public
export interface AuthResultData {
headers: AuthHeaders;
state: Record<string, unknown>;
}
// @public
export interface AuthToolkit {
authenticated: (state?: object) => AuthResult;
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
redirected: (url: string) => AuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
@ -49,10 +58,8 @@ export interface CallAPIOptions {
// @public
export class ClusterClient {
constructor(config: ElasticsearchClientConfig, log: Logger);
asScoped(req?: {
headers?: Headers;
}): ScopedClusterClient;
constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders);
asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient;
callAsInternalUser: (endpoint: string, clientParams?: Record<string, unknown>, options?: CallAPIOptions | undefined) => Promise<any>;
close(): void;
}
@ -128,6 +135,14 @@ export interface ElasticsearchServiceSetup {
};
}
// @public
export interface FakeRequest {
headers: Record<string, string>;
}
// @public
export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined;
// @public (undocumented)
export type Headers = Record<string, string | string[] | undefined>;
@ -168,18 +183,16 @@ export interface InternalCoreStart {
export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
// @internal (undocumented)
protected readonly [requestSymbol]: Request;
constructor(request: Request, params: Params, query: Query, body: Body);
constructor(request: Request, params: Params, query: Query, body: Body, withoutSecretHeaders: boolean);
// (undocumented)
readonly body: Body;
// Warning: (ae-forgotten-export) The symbol "RouteSchemas" needs to be exported by the entry point index.d.ts
//
// @internal
static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(req: Request, routeSchemas?: RouteSchemas<P, Q, B>): KibanaRequest<P["type"], Q["type"], B["type"]>;
static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(req: Request, routeSchemas?: RouteSchemas<P, Q, B>, withoutSecretHeaders?: boolean): KibanaRequest<P["type"], Q["type"], B["type"]>;
// (undocumented)
getFilteredHeaders(headersToKeep: string[]): Pick<Record<string, string | string[] | undefined>, string>;
// (undocumented)
readonly headers: Headers;
// (undocumented)
readonly params: Params;
// (undocumented)
readonly query: Query;
@ -199,6 +212,9 @@ export interface KibanaRequestRoute {
path: string;
}
// @public
export type LegacyRequest = Request;
// @public
export interface Logger {
debug(message: string, meta?: LogMeta): void;
@ -397,7 +413,7 @@ export interface SessionStorage<T> {
// @public
export interface SessionStorageFactory<T> {
// (undocumented)
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
asScoped: (request: KibanaRequest) => SessionStorage<T>;
}

View file

@ -61,7 +61,9 @@ export class Server {
const httpSetup = await this.http.setup();
this.registerDefaultRoute(httpSetup);
const elasticsearchServiceSetup = await this.elasticsearch.setup();
const elasticsearchServiceSetup = await this.elasticsearch.setup({
http: httpSetup,
});
const pluginsSetup = await this.plugins.setup({
elasticsearch: elasticsearchServiceSetup,

View file

@ -17,8 +17,8 @@
* under the License.
*/
export function mapToObject(map: Map<string, unknown>) {
const result: Record<string, unknown> = Object.create(null);
export function mapToObject<V = unknown>(map: Map<string, V>) {
const result: Record<string, V> = Object.create(null);
for (const [key, value] of map) {
result[key] = value;
}

View file

@ -17,8 +17,9 @@
* under the License.
*/
import { Request } from 'hapi';
import { errors } from 'elasticsearch';
import { CallAPIOptions, ClusterClient } from 'kibana/server';
import { CallAPIOptions, ClusterClient, FakeRequest } from 'kibana/server';
export class Cluster {
public readonly errors = errors;
@ -26,7 +27,7 @@ export class Cluster {
constructor(private readonly clusterClient: ClusterClient) {}
public callWithRequest = async (
req: { headers?: Record<string, string> } = {},
req: Request | FakeRequest,
endpoint: string,
clientParams?: Record<string, unknown>,
options?: CallAPIOptions