Support authenticating to Elasticsearch via service account tokens (#102121) (#105286)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
# Conflicts:
#	src/core/server/elasticsearch/elasticsearch_config.ts
This commit is contained in:
Larry Gregory 2021-07-12 16:34:44 -04:00 committed by GitHub
parent f850cd737c
commit 6a47b49f83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 327 additions and 20 deletions

View file

@ -45,6 +45,10 @@
#elasticsearch.username: "kibana_system"
#elasticsearch.password: "pass"
# Kibana can also authenticate to Elasticsearch via "service account tokens".
# If may use this token instead of a username/password.
# elasticsearch.serviceAccountToken: "my_token"
# Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively.
# These settings enable SSL for outgoing requests from the Kibana server to the browser.
#server.ssl.enabled: false

View file

@ -9,7 +9,7 @@ Configuration options to be used to create a [cluster client](./kibana-plugin-co
<b>Signature:</b>
```typescript
export declare type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'customHeaders' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'requestHeadersWhitelist' | 'sniffInterval' | 'hosts' | 'username' | 'password'> & {
export declare type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'customHeaders' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'requestHeadersWhitelist' | 'sniffInterval' | 'hosts' | 'username' | 'password' | 'serviceAccountToken'> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
ssl?: Partial<ElasticsearchConfig['ssl']>;

View file

@ -31,10 +31,11 @@ export declare class ElasticsearchConfig
| [pingTimeout](./kibana-plugin-core-server.elasticsearchconfig.pingtimeout.md) | | <code>Duration</code> | Timeout after which PING HTTP request will be aborted and retried. |
| [requestHeadersWhitelist](./kibana-plugin-core-server.elasticsearchconfig.requestheaderswhitelist.md) | | <code>string[]</code> | List of Kibana client-side headers to send to Elasticsearch when request scoped cluster client is used. If this is an empty array then \*no\* client-side will be sent. |
| [requestTimeout](./kibana-plugin-core-server.elasticsearchconfig.requesttimeout.md) | | <code>Duration</code> | Timeout after which HTTP request will be aborted and retried. |
| [serviceAccountToken](./kibana-plugin-core-server.elasticsearchconfig.serviceaccounttoken.md) | | <code>string</code> | If Elasticsearch security features are enabled, this setting provides the service account token that the Kibana server users to perform its administrative functions.<!-- -->This is an alternative to specifying a username and password. |
| [shardTimeout](./kibana-plugin-core-server.elasticsearchconfig.shardtimeout.md) | | <code>Duration</code> | Timeout for Elasticsearch to wait for responses from shards. Set to 0 to disable. |
| [sniffInterval](./kibana-plugin-core-server.elasticsearchconfig.sniffinterval.md) | | <code>false &#124; Duration</code> | Interval to perform a sniff operation and make sure the list of nodes is complete. If <code>false</code> then sniffing is disabled. |
| [sniffOnConnectionFault](./kibana-plugin-core-server.elasticsearchconfig.sniffonconnectionfault.md) | | <code>boolean</code> | Specifies whether the client should immediately sniff for a more current list of nodes when a connection dies. |
| [sniffOnStart](./kibana-plugin-core-server.elasticsearchconfig.sniffonstart.md) | | <code>boolean</code> | Specifies whether the client should attempt to detect the rest of the cluster when it is first instantiated. |
| [ssl](./kibana-plugin-core-server.elasticsearchconfig.ssl.md) | | <code>Pick&lt;SslConfigSchema, Exclude&lt;keyof SslConfigSchema, 'certificateAuthorities' &#124; 'keystore' &#124; 'truststore'&gt;&gt; &amp; {</code><br/><code> certificateAuthorities?: string[];</code><br/><code> }</code> | Set of settings configure SSL connection between Kibana and Elasticsearch that are required when <code>xpack.ssl.verification_mode</code> in Elasticsearch is set to either <code>certificate</code> or <code>full</code>. |
| [username](./kibana-plugin-core-server.elasticsearchconfig.username.md) | | <code>string</code> | If Elasticsearch is protected with basic authentication, this setting provides the username that the Kibana server uses to perform its administrative functions. |
| [username](./kibana-plugin-core-server.elasticsearchconfig.username.md) | | <code>string</code> | If Elasticsearch is protected with basic authentication, this setting provides the username that the Kibana server uses to perform its administrative functions. Cannot be used in conjunction with serviceAccountToken. |

View file

@ -0,0 +1,15 @@
<!-- 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; [ElasticsearchConfig](./kibana-plugin-core-server.elasticsearchconfig.md) &gt; [serviceAccountToken](./kibana-plugin-core-server.elasticsearchconfig.serviceaccounttoken.md)
## ElasticsearchConfig.serviceAccountToken property
If Elasticsearch security features are enabled, this setting provides the service account token that the Kibana server users to perform its administrative functions.
This is an alternative to specifying a username and password.
<b>Signature:</b>
```typescript
readonly serviceAccountToken?: string;
```

View file

@ -4,7 +4,7 @@
## ElasticsearchConfig.username property
If Elasticsearch is protected with basic authentication, this setting provides the username that the Kibana server uses to perform its administrative functions.
If Elasticsearch is protected with basic authentication, this setting provides the username that the Kibana server uses to perform its administrative functions. Cannot be used in conjunction with serviceAccountToken.
<b>Signature:</b>

View file

@ -11,7 +11,7 @@
<b>Signature:</b>
```typescript
export declare type LegacyElasticsearchClientConfig = Pick<ConfigOptions, 'keepAlive' | 'log' | 'plugins'> & Pick<ElasticsearchConfig, 'apiVersion' | 'customHeaders' | 'requestHeadersWhitelist' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'hosts' | 'username' | 'password'> & {
export declare type LegacyElasticsearchClientConfig = Pick<ConfigOptions, 'keepAlive' | 'log' | 'plugins'> & Pick<ElasticsearchConfig, 'apiVersion' | 'customHeaders' | 'requestHeadersWhitelist' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'hosts' | 'username' | 'password' | 'serviceAccountToken'> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout'];
sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval'];

View file

@ -284,6 +284,11 @@ the username and password that the {kib} server uses to perform maintenance
on the {kib} index at startup. {kib} users still need to authenticate with
{es}, which is proxied through the {kib} server.
|[[elasticsearch-service-account-token]] `elasticsearch.serviceAccountToken:`
| beta[]. If your {es} is protected with basic authentication, this token provides the credentials
that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting
is an alternative to `elasticsearch.username` and `elasticsearch.password`.
| `enterpriseSearch.host`
| The URL of your Enterprise Search instance

View file

@ -68,12 +68,14 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
delete extraCliOptions.env;
if (opts.dev) {
if (!has('elasticsearch.username')) {
set('elasticsearch.username', 'kibana_system');
}
if (!has('elasticsearch.serviceAccountToken')) {
if (!has('elasticsearch.username')) {
set('elasticsearch.username', 'kibana_system');
}
if (!has('elasticsearch.password')) {
set('elasticsearch.password', 'changeme');
if (!has('elasticsearch.password')) {
set('elasticsearch.password', 'changeme');
}
}
if (opts.ssl) {

View file

@ -204,11 +204,27 @@ describe('parseClientOptions', () => {
);
});
it('adds an authorization header if `serviceAccountToken` is set', () => {
expect(
parseClientOptions(
createConfig({
serviceAccountToken: 'ABC123',
}),
false
)
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: `Bearer ABC123`,
}),
})
);
});
it('does not add auth to the nodes', () => {
const options = parseClientOptions(
createConfig({
username: 'user',
password: 'pass',
serviceAccountToken: 'ABC123',
hosts: ['http://node-A:9200'],
}),
true
@ -252,6 +268,34 @@ describe('parseClientOptions', () => {
]
`);
});
it('does not add the authorization header even if `serviceAccountToken` is set', () => {
expect(
parseClientOptions(
createConfig({
serviceAccountToken: 'ABC123',
}),
true
).headers
).not.toHaveProperty('authorization');
});
it('does not add auth to the nodes even if `serviceAccountToken` is set', () => {
const options = parseClientOptions(
createConfig({
serviceAccountToken: 'ABC123',
hosts: ['http://node-A:9200'],
}),
true
);
expect(options.nodes).toMatchInlineSnapshot(`
Array [
Object {
"url": "http://node-a:9200/",
},
]
`);
});
});
});

View file

@ -29,6 +29,7 @@ export type ElasticsearchClientConfig = Pick<
| 'hosts'
| 'username'
| 'password'
| 'serviceAccountToken'
> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
@ -74,11 +75,16 @@ export function parseClientOptions(
};
}
if (config.username && config.password && !scoped) {
clientOptions.auth = {
username: config.username,
password: config.password,
};
if (!scoped) {
if (config.username && config.password) {
clientOptions.auth = {
username: config.username,
password: config.password,
};
} else if (config.serviceAccountToken) {
// TODO: change once ES client has native support for service account tokens: https://github.com/elastic/elasticsearch-js/issues/1477
clientOptions.headers!.authorization = `Bearer ${config.serviceAccountToken}`;
}
}
clientOptions.nodes = config.hosts.map((host) => convertHost(host));

View file

@ -41,6 +41,7 @@ test('set correct defaults', () => {
"authorization",
],
"requestTimeout": "PT30S",
"serviceAccountToken": undefined,
"shardTimeout": "PT30S",
"sniffInterval": false,
"sniffOnConnectionFault": false,
@ -348,3 +349,22 @@ test('#username throws if equal to "elastic", only while running from source', (
);
expect(() => config.schema.validate(obj, { dist: true })).not.toThrow();
});
test('serviceAccountToken throws if username is also set', () => {
const obj = {
username: 'elastic',
serviceAccountToken: 'abc123',
};
expect(() => config.schema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"[serviceAccountToken]: serviceAccountToken cannot be specified when \\"username\\" is also set."`
);
});
test('serviceAccountToken does not throw if username is not set', () => {
const obj = {
serviceAccountToken: 'abc123',
};
expect(() => config.schema.validate(obj)).not.toThrow();
});

View file

@ -52,6 +52,18 @@ export const configSchema = schema.object({
)
),
password: schema.maybe(schema.string()),
serviceAccountToken: schema.maybe(
schema.conditional(
schema.siblingRef('username'),
schema.never(),
schema.string(),
schema.string({
validate: () => {
return `serviceAccountToken cannot be specified when "username" is also set.`;
},
})
)
),
requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], {
defaultValue: ['authorization'],
}),
@ -239,6 +251,7 @@ export class ElasticsearchConfig {
/**
* If Elasticsearch is protected with basic authentication, this setting provides
* the username that the Kibana server uses to perform its administrative functions.
* Cannot be used in conjunction with serviceAccountToken.
*/
public readonly username?: string;
@ -248,6 +261,14 @@ export class ElasticsearchConfig {
*/
public readonly password?: string;
/**
* If Elasticsearch security features are enabled, this setting provides the service account
* token that the Kibana server users to perform its administrative functions.
*
* This is an alternative to specifying a username and password.
*/
public readonly serviceAccountToken?: string;
/**
* Set of settings configure SSL connection between Kibana and Elasticsearch that
* are required when `xpack.ssl.verification_mode` in Elasticsearch is set to
@ -281,6 +302,7 @@ export class ElasticsearchConfig {
this.healthCheckDelay = rawConfig.healthCheck.delay;
this.username = rawConfig.username;
this.password = rawConfig.password;
this.serviceAccountToken = rawConfig.serviceAccountToken;
this.customHeaders = rawConfig.customHeaders;
const { alwaysPresentCertificate, verificationMode } = rawConfig.ssl;

View file

@ -101,6 +101,30 @@ describe('#callAsInternalUser', () => {
expect(mockEsClientInstance.ping).toHaveBeenLastCalledWith(mockParams);
});
test('sets the authorization header when a service account token is configured', async () => {
clusterClient = new LegacyClusterClient(
{ apiVersion: 'es-version', serviceAccountToken: 'ABC123' } as any,
logger.get(),
'custom-type'
);
const mockResponse = { data: 'ping' };
const mockParams = { param: 'ping' };
mockEsClientInstance.ping.mockImplementation(function mockCall(this: any) {
return Promise.resolve({
context: this,
response: mockResponse,
});
});
await clusterClient.callAsInternalUser('ping', mockParams);
expect(mockEsClientInstance.ping).toHaveBeenCalledWith({
headers: { authorization: 'Bearer ABC123' },
param: 'ping',
});
});
test('correctly deals with nested endpoint', async () => {
const mockResponse = { data: 'authenticate' };
const mockParams = { param: 'authenticate' };
@ -355,6 +379,31 @@ describe('#asScoped', () => {
);
});
test('does not set the authorization header when a service account token is configured', async () => {
clusterClient = new LegacyClusterClient(
{
apiVersion: 'es-version',
requestHeadersWhitelist: ['zero'],
serviceAccountToken: 'ABC123',
} as any,
logger.get(),
'custom-type'
);
clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
);
const expectedHeaders = { zero: '0' };
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
expectedHeaders
);
});
test('both scoped and internal API caller fail if cluster client is closed', async () => {
clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })

View file

@ -147,6 +147,13 @@ export class LegacyClusterClient implements ILegacyClusterClient {
) => {
this.assertIsNotClosed();
if (this.config.serviceAccountToken) {
clientParams.headers = {
...clientParams.headers,
authorization: `Bearer ${this.config.serviceAccountToken}`,
};
}
return await (callAPI.bind(null, this.client) as LegacyAPICaller)(
endpoint,
clientParams,

View file

@ -333,6 +333,128 @@ describe('#auth', () => {
});
});
describe('#serviceAccountToken', () => {
it('is set when #auth is true, and a token is provided', () => {
expect(
parseElasticsearchClientConfig(
{
apiVersion: 'v7.0.0',
customHeaders: { xsrf: 'something' },
sniffOnStart: true,
sniffOnConnectionFault: true,
hosts: ['https://es.local'],
requestHeadersWhitelist: [],
serviceAccountToken: 'ABC123',
},
logger.get(),
'custom-type',
{ auth: true }
)
).toMatchInlineSnapshot(`
Object {
"apiVersion": "v7.0.0",
"hosts": Array [
Object {
"headers": Object {
"x-elastic-product-origin": "kibana",
"xsrf": "something",
},
"host": "es.local",
"path": "/",
"port": "443",
"protocol": "https:",
"query": null,
},
],
"keepAlive": true,
"log": [Function],
"serviceAccountToken": "ABC123",
"sniffOnConnectionFault": true,
"sniffOnStart": true,
}
`);
});
it('is not set when #auth is true, and a token is not provided', () => {
expect(
parseElasticsearchClientConfig(
{
apiVersion: 'v7.0.0',
customHeaders: { xsrf: 'something' },
sniffOnStart: true,
sniffOnConnectionFault: true,
hosts: ['https://es.local'],
requestHeadersWhitelist: [],
},
logger.get(),
'custom-type',
{ auth: true }
)
).toMatchInlineSnapshot(`
Object {
"apiVersion": "v7.0.0",
"hosts": Array [
Object {
"headers": Object {
"x-elastic-product-origin": "kibana",
"xsrf": "something",
},
"host": "es.local",
"path": "/",
"port": "443",
"protocol": "https:",
"query": null,
},
],
"keepAlive": true,
"log": [Function],
"sniffOnConnectionFault": true,
"sniffOnStart": true,
}
`);
});
it('is not set when #auth is false, and a token is provided', () => {
expect(
parseElasticsearchClientConfig(
{
apiVersion: 'v7.0.0',
customHeaders: { xsrf: 'something' },
sniffOnStart: true,
sniffOnConnectionFault: true,
hosts: ['https://es.local'],
requestHeadersWhitelist: [],
serviceAccountToken: 'ABC123',
},
logger.get(),
'custom-type',
{ auth: false }
)
).toMatchInlineSnapshot(`
Object {
"apiVersion": "v7.0.0",
"hosts": Array [
Object {
"headers": Object {
"x-elastic-product-origin": "kibana",
"xsrf": "something",
},
"host": "es.local",
"path": "/",
"port": "443",
"protocol": "https:",
"query": null,
},
],
"keepAlive": true,
"log": [Function],
"sniffOnConnectionFault": true,
"sniffOnStart": true,
}
`);
});
});
describe('#customHeaders', () => {
test('override the default headers', () => {
const headerKey = Object.keys(DEFAULT_HEADERS)[0];

View file

@ -35,6 +35,7 @@ export type LegacyElasticsearchClientConfig = Pick<ConfigOptions, 'keepAlive' |
| 'hosts'
| 'username'
| 'password'
| 'serviceAccountToken'
> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout'];
@ -61,6 +62,7 @@ interface LegacyElasticsearchClientConfigOverrides {
/** @internal */
type ExtendedConfigOptions = ConfigOptions &
Partial<{
serviceAccountToken?: string;
ssl: Partial<{
rejectUnauthorized: boolean;
checkServerIdentity: typeof checkServerIdentity;
@ -106,9 +108,14 @@ export function parseElasticsearchClientConfig(
esClientConfig.sniffInterval = getDurationAsMs(config.sniffInterval);
}
const needsAuth = auth !== false && config.username && config.password;
const needsAuth =
auth !== false && ((config.username && config.password) || config.serviceAccountToken);
if (needsAuth) {
esClientConfig.httpAuth = `${config.username}:${config.password}`;
if (config.username) {
esClientConfig.httpAuth = `${config.username}:${config.password}`;
} else if (config.serviceAccountToken) {
esClientConfig.serviceAccountToken = config.serviceAccountToken;
}
}
if (Array.isArray(config.hosts)) {

View file

@ -345,6 +345,7 @@ export const config: {
hosts: Type<string | string[]>;
username: Type<string | undefined>;
password: Type<string | undefined>;
serviceAccountToken: Type<string | undefined>;
requestHeadersWhitelist: Type<string | string[]>;
customHeaders: Type<Record<string, string>>;
shardTimeout: Type<import("moment").Duration>;
@ -948,7 +949,7 @@ export type ElasticsearchClient = Omit<KibanaClient, 'connectionPool' | 'transpo
};
// @public
export type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'customHeaders' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'requestHeadersWhitelist' | 'sniffInterval' | 'hosts' | 'username' | 'password'> & {
export type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'customHeaders' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'requestHeadersWhitelist' | 'sniffInterval' | 'hosts' | 'username' | 'password' | 'serviceAccountToken'> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
ssl?: Partial<ElasticsearchConfig['ssl']>;
@ -968,6 +969,7 @@ export class ElasticsearchConfig {
readonly pingTimeout: Duration;
readonly requestHeadersWhitelist: string[];
readonly requestTimeout: Duration;
readonly serviceAccountToken?: string;
readonly shardTimeout: Duration;
readonly sniffInterval: false | Duration;
readonly sniffOnConnectionFault: boolean;
@ -1675,7 +1677,7 @@ export class LegacyClusterClient implements ILegacyClusterClient {
}
// @public @deprecated (undocumented)
export type LegacyElasticsearchClientConfig = Pick<ConfigOptions, 'keepAlive' | 'log' | 'plugins'> & Pick<ElasticsearchConfig, 'apiVersion' | 'customHeaders' | 'requestHeadersWhitelist' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'hosts' | 'username' | 'password'> & {
export type LegacyElasticsearchClientConfig = Pick<ConfigOptions, 'keepAlive' | 'log' | 'plugins'> & Pick<ElasticsearchConfig, 'apiVersion' | 'customHeaders' | 'requestHeadersWhitelist' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'hosts' | 'username' | 'password' | 'serviceAccountToken'> & {
pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout'];
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout'];
sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval'];

View file

@ -51,6 +51,7 @@ kibana_vars=(
elasticsearch.pingTimeout
elasticsearch.requestHeadersWhitelist
elasticsearch.requestTimeout
elasticsearch.serviceAccountToken
elasticsearch.shardTimeout
elasticsearch.sniffInterval
elasticsearch.sniffOnConnectionFault