Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/142021.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
area: SQL
issues: []
pr: 142021
summary: Add support for API key to JDBC and CLI
type: enhancement
12 changes: 12 additions & 0 deletions docs/reference/query-languages/sql/sql-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ If security is enabled on your cluster, you can pass the username and password i
$ ./bin/elasticsearch-sql-cli https://sql_user:strongpassword@some.server:9200
```

### API Key Authentication [sql-cli-apikey]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering about --apikey <value> on the command line being visible via ps aux or /proc/<pid>/cmdline. The docs mention not to combine API key with basic auth, but don't
warn about this security aspect. Should this be mentioned in docs, and provide alternative suggestions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'm adding a note to the docs.


As an alternative to basic authentication, you can use API key authentication with the `--apikey` option. API keys can be created using the [Create API key API](docs-content://deploy-manage/api-keys/elasticsearch-api-keys.md). The API key should be provided in its encoded form (the `encoded` value returned by the Create API key API):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--apiKey? (uppercase K)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for consistency, JDBC and CLI use all lowercase parameters.


```bash
$ ./bin/elasticsearch-sql-cli --apikey <encoded-api-key> https://some.server:9200

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Uppercase K?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

```

::::{note}
When using API key authentication, do not include username and password in the URL. The CLI will return an error if both API key and basic authentication credentials are provided.
::::

Once the CLI is running you can use any [query](elasticsearch://reference/query-languages/sql/sql-spec.md) that Elasticsearch supports:

```sql
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/query-languages/sql/sql-jdbc.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,24 @@ $$$jdbc-cfg-timezone$$$
: Basic Authentication password


### API Key Authentication [jdbc-cfg-auth-apikey]

As an alternative to basic authentication, you can use API key authentication. API keys can be created using the [Create API key API](docs-content://deploy-manage/api-keys/elasticsearch-api-keys.md). The API key should be provided in its encoded form (the `encoded` value returned by the Create API key API).

`apiKey`
: Encoded API key for authentication. Cannot be used together with `user`/`password` basic authentication.

::::{note}
When using API key authentication, do not specify `user` or `password`. The driver will return an error if both API key and basic authentication credentials are provided.
::::

Example connection URL using API key:

```text
jdbc:es://http://server:9200/?apiKey=<encoded-api-key>
```


### SSL [jdbc-cfg-ssl]

`ssl` (default `false`)
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/query-languages/sql/sql-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ In case of an encrypted transport, the SSL/TLS support needs to be enabled in El

## Authentication [_authentication]

The authentication support in {{es}} SQL is of two types:
The authentication support in {{es}} SQL is of three types:

Username/Password
: Set these through `user` and `password` properties.

API Key
: Use an API key for authentication by setting the `apiKey` property. API keys can be created using the [Create API key API](docs-content://deploy-manage/api-keys/elasticsearch-api-keys.md). The API key should be provided in its encoded form (the `encoded` value returned by the Create API key API). This is an alternative to username/password authentication and cannot be used together with it. For the CLI, use the `--apikey` command line option.

PKI/X.509
: Use X.509 certificates to authenticate {{es}} SQL to {{es}}. For this, one would need to setup the `keystore` containing the private key and certificate to the appropriate user (configured in {{es}}) and the `truststore` with the CA certificate used to sign the SSL/TLS certificates in the {{es}} cluster. That is, one should setup the key to authenticate {{es}} SQL and also to verify that is the right one. To do so, one should set the `ssl.keystore.location` and `ssl.truststore.location` properties to indicate the `keystore` and `truststore` to use. It is recommended to have these secured through a password in which case `ssl.keystore.pass` and `ssl.truststore.pass` properties are required.

Expand Down
12 changes: 9 additions & 3 deletions libs/cli/src/main/java/org/elasticsearch/cli/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,19 @@ protected void mainWithoutErrorHandling(String[] args, Terminal terminal, Proces
LoggerFactory loggerFactory = LoggerFactory.provider();
if (options.has(silentOption)) {
terminal.setVerbosity(Terminal.Verbosity.SILENT);
loggerFactory.setRootLevel(Level.OFF);
if (loggerFactory != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed now but wasn't needed so far?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a consequence of this PR.
The factory is null since #123742, so it started throwing NPE. I don't know why we didn't notice it before.
The problem is that our CLI does not depend on server, so we can't access LogConfigurator and LoggerFactoryImpl.
Let me see if we can do better, maybe we can setup a custom log factory

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more complex than I thought.
SQL CLI runs as a shadow JAR, so I can't access LoggerFactory.setInstance() directly.
I'll open an issue, but I guess the null checks are enough to unblock the component

loggerFactory.setRootLevel(Level.OFF);
}
} else if (options.has(verboseOption)) {
terminal.setVerbosity(Terminal.Verbosity.VERBOSE);
loggerFactory.setRootLevel(Level.DEBUG);
if (loggerFactory != null) {
loggerFactory.setRootLevel(Level.DEBUG);
}
} else {
terminal.setVerbosity(Terminal.Verbosity.NORMAL);
loggerFactory.setRootLevel(Level.INFO);
if (loggerFactory != null) {
loggerFactory.setRootLevel(Level.INFO);
}
}

execute(terminal, options, processInfo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import java.util.Properties;
import java.util.stream.Collectors;

import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.AUTH_API_KEY;
import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.AUTH_USER;
import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.CONNECT_TIMEOUT;
import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.NETWORK_TIMEOUT;
import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.PAGE_SIZE;
Expand All @@ -33,6 +35,7 @@
import static org.elasticsearch.xpack.sql.client.ConnectionConfiguration.QUERY_TIMEOUT;
import static org.elasticsearch.xpack.sql.jdbc.JdbcConfiguration.URL_FULL_PREFIX;
import static org.elasticsearch.xpack.sql.jdbc.JdbcConfiguration.URL_PREFIX;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;

Expand Down Expand Up @@ -399,4 +402,40 @@ private void assertJdbcSqlException(String wrongSetting, String correctSetting,
JdbcSQLException ex = expectThrows(JdbcSQLException.class, () -> JdbcConfiguration.create(url, props, 0));
assertEquals("Unknown parameter [" + wrongSetting + "]; did you mean [" + correctSetting + "]", ex.getMessage());
}

public void testApiKeyInUrl() throws Exception {
String apiKey = "test_api_key_encoded";
JdbcConfiguration ci = ci(jdbcPrefix() + "test:9200?apiKey=" + apiKey);
assertThat(ci.apiKey(), is(apiKey));
assertNull(ci.authUser());
assertNull(ci.authPass());
}

public void testApiKeyInProperties() throws Exception {
String apiKey = "test_api_key_encoded";
Properties props = new Properties();
props.setProperty(AUTH_API_KEY, apiKey);
JdbcConfiguration ci = JdbcConfiguration.create(jdbcPrefix() + "test:9200", props, 0);
assertThat(ci.apiKey(), is(apiKey));
assertNull(ci.authUser());
assertNull(ci.authPass());
}

public void testApiKeyAndUserMutuallyExclusive() {
String apiKey = "test_api_key_encoded";
Properties props = new Properties();
props.setProperty(AUTH_API_KEY, apiKey);
props.setProperty(AUTH_USER, "user");
JdbcSQLException ex = expectThrows(JdbcSQLException.class, () -> JdbcConfiguration.create(jdbcPrefix() + "test:9200", props, 0));
assertThat(ex.getMessage(), containsString("Cannot use both API key and basic authentication"));
}

public void testApiKeyAndUserInUrlMutuallyExclusive() {
String apiKey = "test_api_key_encoded";
JdbcSQLException ex = expectThrows(
JdbcSQLException.class,
() -> ci(jdbcPrefix() + "test:9200?apiKey=" + apiKey + "&user=testuser")
);
assertThat(ex.getMessage(), containsString("Cannot use both API key and basic authentication"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like comments in code, but in these test classes I think there are too many comments imo.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me remove the redundant comments

* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
package org.elasticsearch.xpack.sql.qa.security;

import org.elasticsearch.client.Request;
import org.elasticsearch.client.Response;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.test.cluster.ElasticsearchCluster;
import org.elasticsearch.test.rest.ESRestTestCase;
import org.elasticsearch.xcontent.json.JsonXContent;
import org.elasticsearch.xpack.sql.qa.cli.EmbeddedCli;
import org.elasticsearch.xpack.sql.qa.cli.EmbeddedCli.ApiKeySecurityConfig;
import org.junit.ClassRule;

import java.io.IOException;
import java.io.InputStream;
import java.util.Map;

import static org.elasticsearch.xpack.sql.qa.security.RestSqlIT.SSL_ENABLED;
import static org.hamcrest.Matchers.containsString;

/**
* Integration tests for CLI connections using API key authentication.
*/
public class CliApiKeyIT extends ESRestTestCase {

@ClassRule
public static ElasticsearchCluster cluster = SqlSecurityTestCluster.getCluster();

@Override
protected String getTestRestCluster() {
return cluster.getHttpAddresses();
}

@Override
protected Settings restClientSettings() {
return RestSqlIT.securitySettings();
}

@Override
protected String getProtocol() {
return SSL_ENABLED ? "https" : "http";
}

/**
* Test that a CLI connection can be established using an API key.
*/
public void testCliConnectionWithApiKey() throws Exception {
// Create an API key with full access
String encodedApiKey = createApiKey("cli_test_key", """
{
"name": "cli_test_key",
"role_descriptors": {
"role": {
"cluster": ["monitor"],
"indices": [
{
"names": ["*"],
"privileges": ["all"]
}
]
}
}
}
""");

// Create a test index
Request createIndex = new Request("PUT", "/test_cli_api_key");
createIndex.setJsonEntity("""
{
"mappings": {
"properties": {
"value": { "type": "integer" }
}
}
}
""");
client().performRequest(createIndex);

// Index a document
Request indexDoc = new Request("PUT", "/test_cli_api_key/_doc/1");
indexDoc.addParameter("refresh", "true");
indexDoc.setJsonEntity("""
{
"value": 123
}
""");
client().performRequest(indexDoc);

// Connect via CLI using the API key
ApiKeySecurityConfig apiKeyConfig = createApiKeySecurityConfig(encodedApiKey);

try (EmbeddedCli cli = new EmbeddedCli(elasticsearchAddress(), true, apiKeyConfig)) {
// Execute a simple query
String result = cli.command("SELECT value FROM test_cli_api_key");
assertThat(result, containsString("value"));
String dataLine = cli.readLine();
// Skip separator line
String valueLine = cli.readLine();
assertThat(valueLine, containsString("123"));
}
}

/**
* Test that an invalid API key results in an authentication error.
*/
public void testCliConnectionWithInvalidApiKey() throws Exception {
ApiKeySecurityConfig apiKeyConfig = new ApiKeySecurityConfig(
SSL_ENABLED,
"invalid_api_key_value",
SSL_ENABLED ? SqlSecurityTestCluster.getKeystorePath() : null,
SSL_ENABLED ? SqlSecurityTestCluster.KEYSTORE_PASSWORD : null
);

try (EmbeddedCli cli = new EmbeddedCli(elasticsearchAddress(), false, apiKeyConfig)) {
String result = cli.command("SELECT 1");
// The CLI shows a communication error when authentication fails
// Read subsequent lines to get the full error message
StringBuilder fullError = new StringBuilder(result);
String line;
while ((line = cli.readLine()) != null && !line.isEmpty()) {
fullError.append(line);
}
String errorMessage = fullError.toString();
// Invalid API key causes authentication failure which manifests as communication error
assertTrue(
"Expected authentication error but got: " + errorMessage,
errorMessage.contains("security_exception") || errorMessage.contains("Communication error")
);
}
}

/**
* Test that API key authentication respects role restrictions.
*/
public void testCliConnectionWithLimitedApiKey() throws Exception {
// Create a restricted index that the API key cannot access
Request createRestrictedIndex = new Request("PUT", "/cli_restricted_index");
createRestrictedIndex.setJsonEntity("""
{
"mappings": {
"properties": {
"secret": { "type": "keyword" }
}
}
}
""");
client().performRequest(createRestrictedIndex);

Request indexRestrictedDoc = new Request("PUT", "/cli_restricted_index/_doc/1");
indexRestrictedDoc.addParameter("refresh", "true");
indexRestrictedDoc.setJsonEntity("""
{
"secret": "confidential"
}
""");
client().performRequest(indexRestrictedDoc);

// Create an API key that only has access to a specific index pattern
String encodedApiKey = createApiKey("cli_limited_key", """
{
"name": "cli_limited_key",
"role_descriptors": {
"role": {
"cluster": ["monitor"],
"indices": [
{
"names": ["cli_allowed_*"],
"privileges": ["read"]
}
]
}
}
}
""");

ApiKeySecurityConfig apiKeyConfig = createApiKeySecurityConfig(encodedApiKey);

try (EmbeddedCli cli = new EmbeddedCli(elasticsearchAddress(), true, apiKeyConfig)) {
// Query to restricted index should fail - the index appears as "Unknown" because
// the API key doesn't have permission to see it
String result = cli.command("SELECT * FROM cli_restricted_index");
// The first line contains "Found 1 problem", the actual error is on the next line
String errorLine = cli.readLine();
assertThat(errorLine, containsString("Unknown index [cli_restricted_index]"));
}
}

private String elasticsearchAddress() {
String cluster = getTestRestCluster();
return cluster.split(",")[0];
}

private String createApiKey(String name, String body) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should clean up these api keys when the test finishes (or fails midway) in an @After method.

Request createApiKey = new Request("POST", "/_security/api_key");
createApiKey.setJsonEntity(body);
Response response = client().performRequest(createApiKey);

try (InputStream content = response.getEntity().getContent()) {
Map<String, Object> responseMap = XContentHelper.convertToMap(JsonXContent.jsonXContent, content, false);
return (String) responseMap.get("encoded");
}
}

private ApiKeySecurityConfig createApiKeySecurityConfig(String apiKey) {
return new ApiKeySecurityConfig(
SSL_ENABLED,
apiKey,
SSL_ENABLED ? SqlSecurityTestCluster.getKeystorePath() : null,
SSL_ENABLED ? SqlSecurityTestCluster.KEYSTORE_PASSWORD : null
);
}
}
Loading
Loading