From c88f93bd856a824208eb7ea4f51285314185aca3 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Fri, 9 Apr 2021 10:37:31 +1000 Subject: [PATCH] Service Accounts - features required for Fleet integration (#71514) * Service Accounts - Initial bootstrap plumbing for essential classes (#70391) This PR is the initial effort to add essential classes for service accounts to lay down the foundation of future works. The classes are wired in places, but not yet been used. Also intentionally left out the actual credential store implementation. It is a good first commit which does not bring in too many changes. * Service Accounts - New CLI tool for managing file tokens (#70454) This is the second PR for service accounts. It adds a new CLI tool elasticsearch-service-tokens to manage file tokens. The file tokens are stored in the service_tokens file under the config directory. Out of the planned create, remove and list sub-commands, this PR only implements the create function since it is the most important one. The other two sub-commands will be handled in separate PRs. * Service Accounts - Authentication with file tokens (#70543) This the 3rd PR for service accounts. It adds support for authentication with file tokens. It also adds a cache for performance so that expensive pbkdf2 hashing does not have to be performed on every request. Adding a cache comes with its own housekeeping work around invalidation. This PR ensures that cache gets invalidated when underlying token file is changed. It does not implement APIs for active invalidation. It will be handled in a separate PR after the API token is in place. * [Test] Service Account - fix test assumption * [Test] Service Accounts - handle token names with leading hyphen (#70983) The CLI tool needs an option terminator (--) for another option names that begin with a hyphen. Otherwise it errors out with message of "not recognized option". The service account token name can begin with a hyphen. Hence we need to use -- when it is the case. An example of equivalent command line is ./bin/elasticsearch-service-tokens create elastic/fleet -- -lead-with-hyphen. * Service Accounts - Fleet integration (#70724) This PR implements rest of the pieces needed for Fleet integration, including: * Get service account role descriptor for authorization * API for creating service account token and storing in the security index * API for list tokens for a service account * New named privilege for manage service account * Mandate HTTP TLS for both service account auth and service account related APIs * Tests for API key related operations using service account * [Test] Service Accounts - Remove colon from invalid token name generator (#71099) The colon character is interpreted as the separate between token name and token secret. So if a token name contains a colon, it is in theory invalid. But the parser takes only the part before the colon as the token name and thus consider it as a valid token name. Subsequent authentication will still fail. But for tests, this generates a different exception and fails the expectation. This PR removes the colon char from being used to generate invalid token names for simplicity. * Fix for 7.x quirks --- .../packaging/test/ArchiveTests.java | 2 + .../packaging/util/Archives.java | 1 + .../elasticsearch/packaging/util/Docker.java | 1 + .../packaging/util/Installation.java | 1 + .../packaging/util/Packages.java | 1 + .../security/get-builtin-privileges.asciidoc | 2 + .../xpack/core/XPackSettings.java | 20 + .../CreateServiceAccountTokenAction.java | 20 + .../CreateServiceAccountTokenRequest.java | 117 +++++ .../CreateServiceAccountTokenResponse.java | 84 ++++ .../GetServiceAccountTokensAction.java | 20 + .../GetServiceAccountTokensRequest.java | 79 +++ .../GetServiceAccountTokensResponse.java | 100 ++++ .../security/action/service/TokenInfo.java | 89 ++++ .../authc/support/UsernamePasswordToken.java | 2 +- .../privilege/ClusterPrivilegeResolver.java | 5 + .../xpack/core/XPackSettingsTests.java | 22 + ...CreateServiceAccountTokenRequestTests.java | 80 +++ ...reateServiceAccountTokenResponseTests.java | 39 ++ .../GetServiceAccountTokensRequestTests.java | 61 +++ .../GetServiceAccountTokensResponseTests.java | 126 +++++ .../xpack/security/operator/Constants.java | 2 + .../security/qa/service-account/build.gradle | 44 ++ .../authc/service/ServiceAccountIT.java | 295 +++++++++++ .../src/javaRestTest/resources/service_tokens | 1 + .../resources/ssl/README.asciidoc | 37 ++ .../src/javaRestTest/resources/ssl/ca.crt | 20 + .../src/javaRestTest/resources/ssl/ca.key | 30 ++ .../src/javaRestTest/resources/ssl/ca.p12 | Bin 0 -> 1130 bytes .../src/javaRestTest/resources/ssl/node.crt | 22 + .../src/javaRestTest/resources/ssl/node.key | 30 ++ .../test/SecuritySingleNodeTestCase.java | 9 + .../ServiceAccountSingleNodeTests.java | 73 +++ .../src/main/bin/elasticsearch-service-tokens | 11 + .../main/bin/elasticsearch-service-tokens.bat | 20 + .../xpack/security/Security.java | 62 ++- ...nsportCreateServiceAccountTokenAction.java | 54 ++ ...ransportGetServiceAccountTokensAction.java | 50 ++ .../xpack/security/authc/ApiKeyService.java | 11 + .../security/authc/AuthenticationService.java | 55 +- .../xpack/security/authc/TokenService.java | 93 ++-- .../authc/file/FileUserPasswdStore.java | 31 +- .../CachingServiceAccountsTokenStore.java | 156 ++++++ .../CompositeServiceAccountsTokenStore.java | 103 ++++ .../authc/service/ElasticServiceAccounts.java | 78 +++ .../FileServiceAccountsTokenStore.java | 162 ++++++ .../authc/service/FileTokensTool.java | 132 +++++ .../IndexServiceAccountsTokenStore.java | 196 ++++++++ .../authc/service/ServiceAccount.java | 92 ++++ .../authc/service/ServiceAccountService.java | 143 ++++++ .../authc/service/ServiceAccountToken.java | 173 +++++++ .../service/ServiceAccountsTokenStore.java | 31 ++ .../authc/support/HttpTlsRuntimeCheck.java | 67 +++ .../authz/store/CompositeRolesStore.java | 96 ++-- .../RestCreateServiceAccountTokenAction.java | 64 +++ .../RestGetServiceAccountTokensAction.java | 50 ++ .../security/support/FileLineParser.java | 33 ++ .../security/support/FileReloadListener.java | 40 ++ .../test/SecuritySettingsSource.java | 5 + ...tCreateServiceAccountTokenActionTests.java | 108 ++++ ...ortGetServiceAccountTokensActionTests.java | 103 ++++ .../TransportInvalidateTokenActionTests.java | 12 +- .../authc/AuthenticationServiceTests.java | 69 ++- .../security/authc/TokenServiceTests.java | 85 ++-- ...CachingServiceAccountsTokenStoreTests.java | 161 ++++++ ...mpositeServiceAccountsTokenStoreTests.java | 180 +++++++ .../service/ElasticServiceAccountsTests.java | 71 +++ .../FileServiceAccountsTokenStoreTests.java | 204 ++++++++ .../IndexServiceAccountsTokenStoreTests.java | 302 +++++++++++ .../authc/service/ServiceAccountIdTests.java | 78 +++ .../service/ServiceAccountServiceTests.java | 468 ++++++++++++++++++ .../service/ServiceAccountTokenTests.java | 119 +++++ .../support/SecondaryAuthenticatorTests.java | 12 +- .../authz/store/CompositeRolesStoreTests.java | 66 ++- .../security/support/FileLineParserTests.java | 39 ++ .../support/FileReloadListenerTests.java | 37 ++ .../security/authc/service/service_tokens | 6 + .../test/privileges/11_builtin.yml | 2 +- .../authc/service/FileTokensToolTests.java | 194 ++++++++ 79 files changed, 5545 insertions(+), 214 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequest.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponse.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequest.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponse.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/TokenInfo.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequestTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponseTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequestTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponseTests.java create mode 100644 x-pack/plugin/security/qa/service-account/build.gradle create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt create mode 100644 x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key create mode 100644 x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java create mode 100755 x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStore.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStore.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccount.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/HttpTlsRuntimeCheck.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestCreateServiceAccountTokenAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestGetServiceAccountTokensAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenActionTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensActionTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStoreTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIdTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java create mode 100644 x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens create mode 100644 x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java b/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java index e72b67ef301e6..66094e52b87d7 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java @@ -459,6 +459,8 @@ public void test94ElasticsearchNodeExecuteCliNotEsHomeWorkDir() throws Exception assertThat(result.stdout, containsString("Sets the passwords for reserved users")); result = sh.run(bin.usersTool + " -h"); assertThat(result.stdout, containsString("Manages elasticsearch file users")); + result = sh.run(bin.serviceTokensTool + " -h"); + assertThat(result.stdout, containsString("Manages elasticsearch service account file-tokens")); }; Platforms.onLinux(action); diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java index 75ed0a9699fb8..88a3861fe156d 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java @@ -199,6 +199,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java index 5442fbf45675d..2045f4255ce42 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Docker.java @@ -472,6 +472,7 @@ private static void verifyDefaultInstallation(Installation es) { "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java index 928e0b9f74d81..e8e6f1061ac0f 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java @@ -189,5 +189,6 @@ public class Executables { public final Executable sqlCli = new Executable("elasticsearch-sql-cli"); public final Executable syskeygenTool = new Executable("elasticsearch-syskeygen"); public final Executable usersTool = new Executable("elasticsearch-users"); + public final Executable serviceTokensTool = new Executable("elasticsearch-service-tokens"); } } diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java index 677b5d8763e61..167bc041b8d52 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/Packages.java @@ -227,6 +227,7 @@ private static void verifyDefaultInstallation(Installation es, Distribution dist "elasticsearch-sql-cli", "elasticsearch-syskeygen", "elasticsearch-users", + "elasticsearch-service-tokens", "x-pack-env", "x-pack-security-env", "x-pack-watcher-env" diff --git a/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc b/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc index cd7caffa85ffd..36043cffd5eeb 100644 --- a/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc +++ b/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc @@ -1,3 +1,4 @@ + [role="xpack"] [[security-api-get-builtin-privileges]] === Get builtin privileges API @@ -83,6 +84,7 @@ A successful call returns an object with "cluster" and "index" fields. "manage_rollup", "manage_saml", "manage_security", + "manage_service_account", "manage_slm", "manage_token", "manage_transform", diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java index 052dc0778ffd7..f1baf4ddf2a7b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java @@ -202,6 +202,26 @@ private XPackSettings() { public static final Setting DIAGNOSE_TRUST_EXCEPTIONS_SETTING = Setting.boolSetting( "xpack.security.ssl.diagnose.trust", true, Setting.Property.NodeScope); + // TODO: This setting of hashing algorithm can share code with the one for password when pbkdf2_stretch is the default for both + public static final Setting SERVICE_TOKEN_HASHING_ALGORITHM = new Setting<>( + new Setting.SimpleKey("xpack.security.authc.service_token_hashing.algorithm"), + (s) -> "PBKDF2_STRETCH", + Function.identity(), + v -> { + if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) { + throw new IllegalArgumentException("Invalid algorithm: " + v + ". Valid values for password hashing are " + + Hasher.getAvailableAlgoStoredHash().toString()); + } else if (v.regionMatches(true, 0, "pbkdf2", 0, "pbkdf2".length())) { + try { + SecretKeyFactory.getInstance("PBKDF2withHMACSHA512"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException( + "Support for PBKDF2WithHMACSHA512 must be available in order to use any of the " + + "PBKDF2 algorithms for the [xpack.security.authc.service_token_hashing.algorithm] setting.", e); + } + } + }, Property.NodeScope); + public static final List DEFAULT_SUPPORTED_PROTOCOLS; static { diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenAction.java new file mode 100644 index 0000000000000..4771196c04fde --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenAction.java @@ -0,0 +1,20 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionType; + +public class CreateServiceAccountTokenAction extends ActionType { + + public static final String NAME = "cluster:admin/xpack/security/service_account/token/create"; + public static final CreateServiceAccountTokenAction INSTANCE = new CreateServiceAccountTokenAction(); + + private CreateServiceAccountTokenAction() { + super(NAME, CreateServiceAccountTokenResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequest.java new file mode 100644 index 0000000000000..d841e0011e5ca --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequest.java @@ -0,0 +1,117 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public class CreateServiceAccountTokenRequest extends ActionRequest { + + private final String namespace; + private final String serviceName; + private final String tokenName; + private WriteRequest.RefreshPolicy refreshPolicy = WriteRequest.RefreshPolicy.WAIT_UNTIL; + + public CreateServiceAccountTokenRequest(String namespace, String serviceName, String tokenName) { + this.namespace = namespace; + this.serviceName = serviceName; + this.tokenName = tokenName; + } + + public CreateServiceAccountTokenRequest(StreamInput in) throws IOException { + super(in); + this.namespace = in.readString(); + this.serviceName = in.readString(); + this.tokenName = in.readString(); + this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); + } + + public String getNamespace() { + return namespace; + } + + public String getServiceName() { + return serviceName; + } + + public String getTokenName() { + return tokenName; + } + + public WriteRequest.RefreshPolicy getRefreshPolicy() { + return refreshPolicy; + } + + public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { + this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CreateServiceAccountTokenRequest that = (CreateServiceAccountTokenRequest) o; + return Objects.equals(namespace, that.namespace) && Objects.equals(serviceName, that.serviceName) + && Objects.equals(tokenName, that.tokenName) && refreshPolicy == that.refreshPolicy; + } + + @Override + public int hashCode() { + return Objects.hash(namespace, serviceName, tokenName, refreshPolicy); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(namespace); + out.writeString(serviceName); + out.writeString(tokenName); + refreshPolicy.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(namespace)) { + validationException = addValidationError("service account namespace is required", validationException); + } + + if (Strings.isNullOrEmpty(serviceName)) { + validationException = addValidationError("service account service-name is required", validationException); + } + + if (Strings.isNullOrEmpty(tokenName)) { + validationException = addValidationError("service account token name is required", validationException); + } else { + if (tokenName.length() > 256) { + validationException = addValidationError( + "service account token name may not be more than 256 characters long", validationException); + } + if (tokenName.equals(tokenName.trim()) == false) { + validationException = addValidationError( + "service account token name may not begin or end with whitespace", validationException); + } + if (tokenName.startsWith("_")) { + validationException = addValidationError( + "service account token name may not begin with an underscore", validationException); + } + } + return validationException; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponse.java new file mode 100644 index 0000000000000..605467a3f0290 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponse.java @@ -0,0 +1,84 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Objects; + +public class CreateServiceAccountTokenResponse extends ActionResponse implements ToXContentObject { + + @Nullable + private final String name; + @Nullable + private final SecureString value; + + private CreateServiceAccountTokenResponse(boolean created, String name, SecureString value) { + this.name = name; + this.value = value; + } + + public CreateServiceAccountTokenResponse(StreamInput in) throws IOException { + super(in); + this.name = in.readOptionalString(); + this.value = in.readOptionalSecureString(); + } + + public String getName() { + return name; + } + + public SecureString getValue() { + return value; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject() + .field("created", true) + .field("token") + .startObject() + .field("name", name) + .field("value", value.toString()) + .endObject() + .endObject(); + return builder; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeOptionalString(name); + out.writeOptionalSecureString(value); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CreateServiceAccountTokenResponse that = (CreateServiceAccountTokenResponse) o; + return Objects.equals(name, that.name) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + + public static CreateServiceAccountTokenResponse created(String name, SecureString value) { + return new CreateServiceAccountTokenResponse(true, name, value); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensAction.java new file mode 100644 index 0000000000000..b914272b9700b --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensAction.java @@ -0,0 +1,20 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionType; + +public class GetServiceAccountTokensAction extends ActionType { + + public static final String NAME = "cluster:admin/xpack/security/service_account/token/get"; + public static final GetServiceAccountTokensAction INSTANCE = new GetServiceAccountTokensAction(); + + public GetServiceAccountTokensAction() { + super(NAME, GetServiceAccountTokensResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequest.java new file mode 100644 index 0000000000000..5331c5a4f6e2a --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequest.java @@ -0,0 +1,79 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public class GetServiceAccountTokensRequest extends ActionRequest { + + private final String namespace; + private final String serviceName; + + public GetServiceAccountTokensRequest(String namespace, String serviceName) { + this.namespace = namespace; + this.serviceName = serviceName; + } + + public GetServiceAccountTokensRequest(StreamInput in) throws IOException { + super(in); + this.namespace = in.readString(); + this.serviceName = in.readString(); + } + + public String getNamespace() { + return namespace; + } + + public String getServiceName() { + return serviceName; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + GetServiceAccountTokensRequest that = (GetServiceAccountTokensRequest) o; + return Objects.equals(namespace, that.namespace) && Objects.equals(serviceName, that.serviceName); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, serviceName); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(namespace); + out.writeString(serviceName); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(namespace)) { + validationException = addValidationError("service account namespace is required", validationException); + } + + if (Strings.isNullOrEmpty(serviceName)) { + validationException = addValidationError("service account service-name is required", validationException); + } + return validationException; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponse.java new file mode 100644 index 0000000000000..760fc6c0ac252 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponse.java @@ -0,0 +1,100 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.groupingBy; + +public class GetServiceAccountTokensResponse extends ActionResponse implements ToXContentObject { + + private final String principal; + private final String nodeName; + private final List tokenInfos; + + public GetServiceAccountTokensResponse(String principal, String nodeName, Collection tokenInfos) { + this.principal = principal; + this.nodeName = nodeName; + this.tokenInfos = tokenInfos == null ? + org.elasticsearch.common.collect.List.of() : + org.elasticsearch.common.collect.List.copyOf(tokenInfos.stream().sorted().collect(Collectors.toList())); + } + + public GetServiceAccountTokensResponse(StreamInput in) throws IOException { + super(in); + this.principal = in.readString(); + this.nodeName = in.readString(); + this.tokenInfos = in.readList(TokenInfo::new); + } + + public String getPrincipal() { + return principal; + } + + public String getNodeName() { + return nodeName; + } + + public Collection getTokenInfos() { + return tokenInfos; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(principal); + out.writeString(nodeName); + out.writeList(tokenInfos); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + final Map> tokenInfosBySource = + tokenInfos.stream().collect(groupingBy(TokenInfo::getSource, Collectors.toList())); + builder.startObject() + .field("service_account", principal) + .field("node_name", nodeName) + .field("count", tokenInfos.size()) + .field("tokens").startObject(); + for (TokenInfo info : tokenInfosBySource.getOrDefault(TokenInfo.TokenSource.INDEX, org.elasticsearch.common.collect.List.of())) { + info.toXContent(builder, params); + } + builder.endObject().field("file_tokens").startObject(); + for (TokenInfo info : tokenInfosBySource.getOrDefault(TokenInfo.TokenSource.FILE, org.elasticsearch.common.collect.List.of())) { + info.toXContent(builder, params); + } + builder.endObject().endObject(); + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + GetServiceAccountTokensResponse that = (GetServiceAccountTokensResponse) o; + return Objects.equals(principal, that.principal) && Objects.equals(nodeName, that.nodeName) && Objects.equals( + tokenInfos, that.tokenInfos); + } + + @Override + public int hashCode() { + return Objects.hash(principal, nodeName, tokenInfos); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/TokenInfo.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/TokenInfo.java new file mode 100644 index 0000000000000..c2e03c1b5d040 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/TokenInfo.java @@ -0,0 +1,89 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Objects; + +public class TokenInfo implements Writeable, ToXContentObject, Comparable { + + private final String name; + private final TokenSource source; + + private TokenInfo(String name, TokenSource source) { + this.name = name; + this.source = source; + } + + public TokenInfo(StreamInput in) throws IOException { + this.name = in.readString(); + this.source = in.readEnum(TokenSource.class); + } + + public String getName() { + return name; + } + + public TokenSource getSource() { + return source; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + TokenInfo tokenInfo = (TokenInfo) o; + return Objects.equals(name, tokenInfo.name) && source == tokenInfo.source; + } + + @Override + public int hashCode() { + return Objects.hash(name, source); + } + + public static TokenInfo indexToken(String name) { + return new TokenInfo(name, TokenSource.INDEX); + } + + public static TokenInfo fileToken(String name) { + return new TokenInfo(name, TokenSource.FILE); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.field(name, org.elasticsearch.common.collect.Map.of()); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeEnum(source); + } + + @Override + public int compareTo(TokenInfo o) { + final int score = source.compareTo(o.source); + if (score == 0) { + return name.compareTo(o.name); + } else { + return score; + } + } + + public enum TokenSource { + INDEX, FILE; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java index 56ca0ddfe82f1..5ace37c4c30f9 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java @@ -126,7 +126,7 @@ public static void putTokenHeader(ThreadContext context, UsernamePasswordToken t /** * Like String.indexOf for for an array of chars */ - private static int indexOfColon(char[] array) { + public static int indexOfColon(char[] array) { for (int i = 0; (i < array.length); i++) { if (array[i] == ':') { return i; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java index cb0259cef3528..8e3a5b6bc330d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java @@ -60,6 +60,8 @@ public class ClusterPrivilegeResolver { private static final Set MANAGE_TOKEN_PATTERN = Collections.singleton("cluster:admin/xpack/security/token/*"); private static final Set MANAGE_API_KEY_PATTERN = Collections.singleton("cluster:admin/xpack/security/api_key/*"); private static final Set GRANT_API_KEY_PATTERN = Collections.singleton(GrantApiKeyAction.NAME + "*"); + private static final Set MANAGE_SERVICE_ACCOUNT_PATTERN = + Collections.singleton("cluster:admin/xpack/security/service_account/*"); private static final Set MONITOR_PATTERN = Collections.singleton("cluster:monitor/*"); private static final Set MONITOR_TEXT_STRUCTURE_PATTERN = Collections.singleton("cluster:monitor/text_structure/*"); private static final Set MONITOR_TRANSFORM_PATTERN = Collections.unmodifiableSet( @@ -137,6 +139,8 @@ public class ClusterPrivilegeResolver { public static final NamedClusterPrivilege MANAGE_SAML = new ActionClusterPrivilege("manage_saml", MANAGE_SAML_PATTERN); public static final NamedClusterPrivilege MANAGE_OIDC = new ActionClusterPrivilege("manage_oidc", MANAGE_OIDC_PATTERN); public static final NamedClusterPrivilege MANAGE_API_KEY = new ActionClusterPrivilege("manage_api_key", MANAGE_API_KEY_PATTERN); + public static final NamedClusterPrivilege MANAGE_SERVICE_ACCOUNT = new ActionClusterPrivilege("manage_service_account", + MANAGE_SERVICE_ACCOUNT_PATTERN); public static final NamedClusterPrivilege GRANT_API_KEY = new ActionClusterPrivilege("grant_api_key", GRANT_API_KEY_PATTERN); public static final NamedClusterPrivilege MANAGE_PIPELINE = new ActionClusterPrivilege("manage_pipeline", Collections.singleton("cluster:admin/ingest/pipeline/*")); @@ -188,6 +192,7 @@ public class ClusterPrivilegeResolver { MANAGE_OIDC, MANAGE_API_KEY, GRANT_API_KEY, + MANAGE_SERVICE_ACCOUNT, MANAGE_PIPELINE, MANAGE_ROLLUP, MANAGE_AUTOSCALING, diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java index 610cbc2efd413..64ca7c1f852f6 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/XPackSettingsTests.java @@ -16,6 +16,7 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.not; @@ -61,6 +62,27 @@ public void testDefaultSupportedProtocolsWithoutTLSv13() throws Exception { assertThat(XPackSettings.DEFAULT_SUPPORTED_PROTOCOLS, contains("TLSv1.2", "TLSv1.1")); } + public void testServiceTokenHashingAlgorithmSettingValidation() { + final boolean isPBKDF2Available = isSecretkeyFactoryAlgoAvailable("PBKDF2WithHMACSHA512"); + final String pbkdf2Algo = randomFrom("PBKDF2_10000", "PBKDF2", "PBKDF2_STRETCH"); + final Settings settings = Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), pbkdf2Algo).build(); + if (isPBKDF2Available) { + assertEquals(pbkdf2Algo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings)); + } else { + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, + () -> XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings)); + assertThat(e.getMessage(), containsString("Support for PBKDF2WithHMACSHA512 must be available")); + } + + final String bcryptAlgo = randomFrom("BCRYPT", "BCRYPT11"); + assertEquals(bcryptAlgo, XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get( + Settings.builder().put(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.getKey(), bcryptAlgo).build())); + } + + public void testDefaultServiceTokenHashingAlgorithm() { + assertThat(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(Settings.EMPTY), equalTo("PBKDF2_STRETCH")); + } + private boolean isSecretkeyFactoryAlgoAvailable(String algorithmId) { try { SecretKeyFactory.getInstance(algorithmId); diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequestTests.java new file mode 100644 index 0000000000000..c589200b2fede --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenRequestTests.java @@ -0,0 +1,80 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.InputStreamStreamInput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.test.ESTestCase; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +public class CreateServiceAccountTokenRequestTests extends ESTestCase { + + public void testReadWrite() throws IOException { + final CreateServiceAccountTokenRequest request = new CreateServiceAccountTokenRequest( + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8)); + try (BytesStreamOutput out = new BytesStreamOutput()) { + request.writeTo(out); + try (StreamInput in = new InputStreamStreamInput(new ByteArrayInputStream(out.bytes().array()))) { + assertThat(new CreateServiceAccountTokenRequest(in), equalTo(request)); + } + } + } + + public void testValidation() { + final String namespace = randomAlphaOfLengthBetween(3, 8); + final String serviceName = randomAlphaOfLengthBetween(3, 8); + final String tokenName = randomAlphaOfLengthBetween(3, 8); + + final CreateServiceAccountTokenRequest request1 = + new CreateServiceAccountTokenRequest(randomFrom("", null), serviceName, tokenName); + final ActionRequestValidationException validation1 = request1.validate(); + assertThat(validation1.validationErrors(), contains(containsString("namespace is required"))); + + final CreateServiceAccountTokenRequest request2 = + new CreateServiceAccountTokenRequest(namespace, randomFrom("", null), tokenName); + final ActionRequestValidationException validation2 = request2.validate(); + assertThat(validation2.validationErrors(), contains(containsString("service-name is required"))); + + final CreateServiceAccountTokenRequest request3 = + new CreateServiceAccountTokenRequest(namespace, serviceName, randomFrom("", null)); + final ActionRequestValidationException validation3 = request3.validate(); + assertThat(validation3.validationErrors(), contains(containsString("token name is required"))); + + final CreateServiceAccountTokenRequest request4 = new CreateServiceAccountTokenRequest(namespace, serviceName, + randomFrom(" " + tokenName, tokenName + " ", " " + tokenName + " ")); + final ActionRequestValidationException validation4 = request4.validate(); + assertThat(validation4.validationErrors(), contains(containsString( + "service account token name may not begin or end with whitespace"))); + + final CreateServiceAccountTokenRequest request5 = new CreateServiceAccountTokenRequest(namespace, serviceName, "_" + tokenName); + final ActionRequestValidationException validation5 = request5.validate(); + assertThat(validation5.validationErrors(), contains(containsString( + "service account token name may not begin with an underscore"))); + + final CreateServiceAccountTokenRequest request6 = new CreateServiceAccountTokenRequest(namespace, serviceName, + randomAlphaOfLength(257)); + final ActionRequestValidationException validation6 = request6.validate(); + assertThat(validation6.validationErrors(), contains(containsString( + "service account token name may not be more than 256 characters long"))); + + final CreateServiceAccountTokenRequest request7 = new CreateServiceAccountTokenRequest(namespace, serviceName, tokenName); + final ActionRequestValidationException validation7 = request7.validate(); + assertThat(validation7, nullValue()); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponseTests.java new file mode 100644 index 0000000000000..1ade9c0ab9c03 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/CreateServiceAccountTokenResponseTests.java @@ -0,0 +1,39 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; + +public class CreateServiceAccountTokenResponseTests extends AbstractWireSerializingTestCase { + + @Override + protected Writeable.Reader instanceReader() { + return CreateServiceAccountTokenResponse::new; + } + + @Override + protected CreateServiceAccountTokenResponse createTestInstance() { + return CreateServiceAccountTokenResponse.created( + randomAlphaOfLengthBetween(3, 8), new SecureString(randomAlphaOfLength(20).toCharArray())); + } + + @Override + protected CreateServiceAccountTokenResponse mutateInstance(CreateServiceAccountTokenResponse instance) throws IOException { + if (randomBoolean()) { + return CreateServiceAccountTokenResponse.created( + randomValueOtherThan(instance.getName(), () -> randomAlphaOfLengthBetween(3, 8)), instance.getValue()); + } else { + return CreateServiceAccountTokenResponse.created(instance.getName(), + randomValueOtherThan(instance.getValue(), () -> new SecureString(randomAlphaOfLength(22).toCharArray()))); + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequestTests.java new file mode 100644 index 0000000000000..0a9988ec3a8d0 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensRequestTests.java @@ -0,0 +1,61 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; + +import static org.hamcrest.Matchers.containsString; + +public class GetServiceAccountTokensRequestTests extends AbstractWireSerializingTestCase { + + @Override + protected Writeable.Reader instanceReader() { + return GetServiceAccountTokensRequest::new; + } + + @Override + protected GetServiceAccountTokensRequest createTestInstance() { + final String namespace = randomAlphaOfLengthBetween(3, 8); + final String serviceName = randomAlphaOfLengthBetween(3, 8); + return new GetServiceAccountTokensRequest(namespace, serviceName); + } + + @Override + protected GetServiceAccountTokensRequest mutateInstance(GetServiceAccountTokensRequest instance) throws IOException { + switch (randomIntBetween(0, 2)) { + case 0: + return new GetServiceAccountTokensRequest( + randomValueOtherThan(instance.getNamespace(), () -> randomAlphaOfLengthBetween(3, 8)), instance.getServiceName()); + case 1: + return new GetServiceAccountTokensRequest( + instance.getNamespace(), randomValueOtherThan(instance.getServiceName(), () -> randomAlphaOfLengthBetween(3, 8))); + default: + return new GetServiceAccountTokensRequest( + randomValueOtherThan(instance.getNamespace(), () -> randomAlphaOfLengthBetween(3, 8)), + randomValueOtherThan(instance.getServiceName(), () -> randomAlphaOfLengthBetween(3, 8))); + } + } + + public void testValidate() { + assertNull(createTestInstance().validate()); + + final GetServiceAccountTokensRequest request1 = + new GetServiceAccountTokensRequest(randomFrom("", null), randomAlphaOfLengthBetween(3, 8)); + final ActionRequestValidationException e1 = request1.validate(); + assertThat(e1.getMessage(), containsString("service account namespace is required")); + + final GetServiceAccountTokensRequest request2 = + new GetServiceAccountTokensRequest(randomAlphaOfLengthBetween(3, 8), randomFrom("", null)); + final ActionRequestValidationException e2 = request2.validate(); + assertThat(e2.getMessage(), containsString("service account service-name is required")); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponseTests.java new file mode 100644 index 0000000000000..647fe33a22fff --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponseTests.java @@ -0,0 +1,126 @@ +/* + * 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.core.security.action.service; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class GetServiceAccountTokensResponseTests extends AbstractWireSerializingTestCase { + + @Override + protected Writeable.Reader instanceReader() { + return GetServiceAccountTokensResponse::new; + } + + @Override + protected GetServiceAccountTokensResponse createTestInstance() { + final String principal = randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8); + final String nodeName = randomAlphaOfLengthBetween(3, 8); + final List tokenInfos = IntStream.range(0, randomIntBetween(0, 10)) + .mapToObj(i -> randomTokenInfo()) + .collect(Collectors.toList()); + return new GetServiceAccountTokensResponse(principal, nodeName, tokenInfos); + } + + @Override + protected GetServiceAccountTokensResponse mutateInstance(GetServiceAccountTokensResponse instance) throws IOException { + + switch (randomIntBetween(0, 2)) { + case 0: + return new GetServiceAccountTokensResponse(randomValueOtherThan(instance.getPrincipal(), + () -> randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8)), + instance.getNodeName(), instance.getTokenInfos()); + case 1: + return new GetServiceAccountTokensResponse(instance.getPrincipal(), + randomValueOtherThan(instance.getNodeName(), () -> randomAlphaOfLengthBetween(3, 8)), + instance.getTokenInfos()); + default: + final ArrayList tokenInfos = new ArrayList<>(instance.getTokenInfos()); + switch (randomIntBetween(0, 2)) { + case 0: + if (false == tokenInfos.isEmpty()) { + tokenInfos.remove(randomIntBetween(0, tokenInfos.size() - 1)); + } else { + tokenInfos.add(randomTokenInfo()); + } + break; + case 1: + tokenInfos.add(randomIntBetween(0, tokenInfos.isEmpty() ? 0 : tokenInfos.size() - 1), randomTokenInfo()); + break; + default: + if (false == tokenInfos.isEmpty()) { + for (int i = 0; i < randomIntBetween(1, tokenInfos.size()); i++) { + final int j = randomIntBetween(0, tokenInfos.size() - 1); + tokenInfos.set(j, randomValueOtherThan(tokenInfos.get(j), this::randomTokenInfo)); + } + } else { + tokenInfos.add(randomTokenInfo()); + } + } + return new GetServiceAccountTokensResponse(instance.getPrincipal(), instance.getNodeName(), new ArrayList<>(tokenInfos)); + } + } + + public void testEquals() { + final GetServiceAccountTokensResponse response = createTestInstance(); + final ArrayList tokenInfos = new ArrayList<>(response.getTokenInfos()); + Collections.shuffle(tokenInfos, random()); + assertThat(new GetServiceAccountTokensResponse( + response.getPrincipal(), response.getNodeName(), new ArrayList<>(tokenInfos)), + equalTo(response)); + } + + public void testToXContent() throws IOException { + final GetServiceAccountTokensResponse response = createTestInstance(); + final Map nameToTokenInfos = response.getTokenInfos().stream() + .collect(Collectors.toMap(TokenInfo::getName, Function.identity())); + XContentBuilder builder = XContentFactory.jsonBuilder(); + response.toXContent(builder, ToXContent.EMPTY_PARAMS); + final Map responseMap = XContentHelper.convertToMap(BytesReference.bytes(builder), + false, builder.contentType()).v2(); + + assertThat(responseMap.get("service_account"), equalTo(response.getPrincipal())); + assertThat(responseMap.get("node_name"), equalTo(response.getNodeName())); + assertThat(responseMap.get("count"), equalTo(response.getTokenInfos().size())); + @SuppressWarnings("unchecked") + final Map tokens = (Map) responseMap.get("tokens"); + assertNotNull(tokens); + tokens.keySet().forEach(k -> assertThat(nameToTokenInfos.remove(k).getSource(), equalTo(TokenInfo.TokenSource.INDEX))); + + @SuppressWarnings("unchecked") + final Map fileTokens = (Map) responseMap.get("file_tokens"); + assertNotNull(fileTokens); + fileTokens.keySet().forEach(k -> assertThat(nameToTokenInfos.remove(k).getSource(), equalTo(TokenInfo.TokenSource.FILE))); + + assertThat(nameToTokenInfos, is(anEmptyMap())); + } + + private TokenInfo randomTokenInfo() { + return randomBoolean() ? + TokenInfo.fileToken(randomAlphaOfLengthBetween(3, 8)) : + TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8)); + } +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 9ea9f7b73048f..93a59bdb32bd9 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -190,6 +190,8 @@ public class Constants { "cluster:admin/xpack/security/saml/invalidate", "cluster:admin/xpack/security/saml/logout", "cluster:admin/xpack/security/saml/prepare", + "cluster:admin/xpack/security/service_account/token/create", + "cluster:admin/xpack/security/service_account/token/get", "cluster:admin/xpack/security/token/create", "cluster:admin/xpack/security/token/invalidate", "cluster:admin/xpack/security/token/refresh", diff --git a/x-pack/plugin/security/qa/service-account/build.gradle b/x-pack/plugin/security/qa/service-account/build.gradle new file mode 100644 index 0000000000000..d77ecb7095e72 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'elasticsearch.java-rest-test' + +dependencies { + javaRestTestImplementation project(':x-pack:plugin:core') + javaRestTestImplementation project(':client:rest-high-level') + javaRestTestImplementation project(':x-pack:plugin:security') + // let the javaRestTest see the classpath of main + javaRestTestImplementation project.sourceSets.main.runtimeClasspath +} + +testClusters.javaRestTest { + testDistribution = 'DEFAULT' + numberOfNodes = 2 + + extraConfigFile 'node.key', file('src/javaRestTest/resources/ssl/node.key') + extraConfigFile 'node.crt', file('src/javaRestTest/resources/ssl/node.crt') + extraConfigFile 'ca.crt', file('src/javaRestTest/resources/ssl/ca.crt') + extraConfigFile 'service_tokens', file('src/javaRestTest/resources/service_tokens') + + setting 'xpack.ml.enabled', 'false' + setting 'xpack.license.self_generated.type', 'trial' + + setting 'xpack.security.enabled', 'true' + setting 'xpack.security.authc.token.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' + + setting 'xpack.security.http.ssl.enabled', 'true' + setting 'xpack.security.http.ssl.certificate', 'node.crt' + setting 'xpack.security.http.ssl.key', 'node.key' + setting 'xpack.security.http.ssl.certificate_authorities', 'ca.crt' + + setting 'xpack.security.transport.ssl.enabled', 'true' + setting 'xpack.security.transport.ssl.certificate', 'node.crt' + setting 'xpack.security.transport.ssl.key', 'node.key' + setting 'xpack.security.transport.ssl.certificate_authorities', 'ca.crt' + setting 'xpack.security.transport.ssl.verification_mode', 'certificate' + + keystore 'bootstrap.password', 'x-pack-test-password' + keystore 'xpack.security.transport.ssl.secure_key_passphrase', 'node-password' + keystore 'xpack.security.http.ssl.secure_key_passphrase', 'node-password' + + user username: "test_admin", password: 'x-pack-test-password', role: "superuser" + user username: "elastic/fleet-server", password: 'x-pack-test-password', role: "superuser" +} diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java b/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java new file mode 100644 index 0000000000000..e2e0f4fd4d71f --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java @@ -0,0 +1,295 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.junit.BeforeClass; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; + +public class ServiceAccountIT extends ESRestTestCase { + + private static final String VALID_SERVICE_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"; + private static final String INVALID_SERVICE_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTozYUpDTGFRV1JOMnNYbE9kdHhBMFNR"; + private static Path caPath; + + private static final String AUTHENTICATE_RESPONSE = "" + + "{\n" + + " \"username\": \"elastic/fleet-server\",\n" + + " \"roles\": [],\n" + + " \"full_name\": \"Service account - elastic/fleet-server\",\n" + + " \"email\": null,\n" + + " \"metadata\": {\n" + + " \"_elastic_service_account\": true\n" + + " },\n" + " \"enabled\": true,\n" + + " \"authentication_realm\": {\n" + + " \"name\": \"service_account\",\n" + + " \"type\": \"service_account\"\n" + + " },\n" + + " \"lookup_realm\": {\n" + + " \"name\": \"service_account\",\n" + + " \"type\": \"service_account\"\n" + + " },\n" + + " \"authentication_type\": \"token\"\n" + + "}\n"; + + @BeforeClass + public static void init() throws URISyntaxException, FileNotFoundException { + URL resource = ServiceAccountIT.class.getResource("/ssl/ca.crt"); + if (resource == null) { + throw new FileNotFoundException("Cannot find classpath resource /ssl/ca.crt"); + } + caPath = PathUtils.get(resource.toURI()); + } + + @Override + protected String getProtocol() { + // Because http.ssl.enabled = true + return "https"; + } + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); + return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token) + .put(CERTIFICATE_AUTHORITIES, caPath) + .build(); + } + + public void testAuthenticate() throws IOException { + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + VALID_SERVICE_TOKEN)); + final Response response = client().performRequest(request); + assertOK(response); + assertThat(responseAsMap(response), + equalTo(XContentHelper.convertToMap(new BytesArray(AUTHENTICATE_RESPONSE), false, XContentType.JSON).v2())); + } + + public void testAuthenticateShouldNotFallThroughInCaseOfFailure() throws IOException { + final boolean securityIndexExists = randomBoolean(); + if (securityIndexExists) { + final Request createRoleRequest = new Request("POST", "_security/role/dummy_role"); + createRoleRequest.setJsonEntity("{\"cluster\":[]}"); + assertOK(client().performRequest(createRoleRequest)); + } + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + INVALID_SERVICE_TOKEN)); + final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(401)); + if (securityIndexExists) { + assertThat(e.getMessage(), containsString( + "failed to authenticate service account [elastic/fleet-server] with token name [token1]")); + } else { + assertThat(e.getMessage(), containsString("no such index [.security]")); + } + } + + public void testAuthenticateShouldWorkWithOAuthBearerToken() throws IOException { + final Request oauthTokenRequest = new Request("POST", "_security/oauth2/token"); + oauthTokenRequest.setJsonEntity("{\"grant_type\":\"password\",\"username\":\"test_admin\",\"password\":\"x-pack-test-password\"}"); + final Response oauthTokenResponse = client().performRequest(oauthTokenRequest); + assertOK(oauthTokenResponse); + final Map oauthTokenResponseMap = responseAsMap(oauthTokenResponse); + final String accessToken = (String) oauthTokenResponseMap.get("access_token"); + + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + accessToken)); + final Response response = client().performRequest(request); + assertOK(response); + final Map responseMap = responseAsMap(response); + assertThat(responseMap.get("username"), equalTo("test_admin")); + assertThat(responseMap.get("authentication_type"), equalTo("token")); + + final String refreshToken = (String) oauthTokenResponseMap.get("refresh_token"); + final Request refreshTokenRequest = new Request("POST", "_security/oauth2/token"); + refreshTokenRequest.setJsonEntity("{\"grant_type\":\"refresh_token\",\"refresh_token\":\"" + refreshToken + "\"}"); + final Response refreshTokenResponse = client().performRequest(refreshTokenRequest); + assertOK(refreshTokenResponse); + } + + public void testAuthenticateShouldDifferentiateBetweenNormalUserAndServiceAccount() throws IOException { + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader( + "Authorization", basicAuthHeaderValue("elastic/fleet-server", new SecureString("x-pack-test-password".toCharArray())) + )); + final Response response = client().performRequest(request); + assertOK(response); + final Map responseMap = responseAsMap(response); + + assertThat(responseMap.get("username"), equalTo("elastic/fleet-server")); + assertThat(responseMap.get("authentication_type"), equalTo("realm")); + assertThat(responseMap.get("roles"), equalTo(org.elasticsearch.common.collect.List.of("superuser"))); + Map authRealm = (Map) responseMap.get("authentication_realm"); + assertThat(authRealm, hasEntry("type", "file")); + } + + public void testCreateApiServiceAccountTokenAndAuthenticateWithIt() throws IOException { + final Request createTokenRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/api-token-1"); + final Response createTokenResponse = client().performRequest(createTokenRequest); + assertOK(createTokenResponse); + final Map createTokenResponseMap = responseAsMap(createTokenResponse); + assertThat(createTokenResponseMap.get("created"), is(true)); + @SuppressWarnings("unchecked") + final Map tokenMap = (Map) createTokenResponseMap.get("token"); + assertThat(tokenMap.get("name"), equalTo("api-token-1")); + + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + tokenMap.get("value"))); + final Response response = client().performRequest(request); + assertOK(response); + assertThat(responseAsMap(response), + equalTo(XContentHelper.convertToMap(new BytesArray(AUTHENTICATE_RESPONSE), false, XContentType.JSON).v2())); + } + + public void testFileTokenAndApiTokenCanShareTheSameNameAndBothWorks() throws IOException { + final Request createTokenRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/token1"); + final Response createTokenResponse = client().performRequest(createTokenRequest); + assertOK(createTokenResponse); + final Map createTokenResponseMap = responseAsMap(createTokenResponse); + assertThat(createTokenResponseMap.get("created"), is(true)); + @SuppressWarnings("unchecked") + final Map tokenMap = (Map) createTokenResponseMap.get("token"); + assertThat(tokenMap.get("name"), equalTo("token1")); + + // The API token works + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + tokenMap.get("value"))); + assertOK(client().performRequest(request)); + + // And the file token also works + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + VALID_SERVICE_TOKEN)); + assertOK(client().performRequest(request)); + } + + public void testNoDuplicateApiServiceAccountToken() throws IOException { + final String tokeName = randomAlphaOfLengthBetween(3, 8); + final Request createTokenRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/" + tokeName); + final Response createTokenResponse = client().performRequest(createTokenRequest); + assertOK(createTokenResponse); + + final ResponseException e = + expectThrows(ResponseException.class, () -> client().performRequest(createTokenRequest)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(409)); + assertThat(e.getMessage(), containsString("document already exists")); + } + + public void testGetServiceAccountTokens() throws IOException { + final Request getTokensRequest = new Request("GET", "_security/service/elastic/fleet-server/credential"); + final Response getTokensResponse1 = client().performRequest(getTokensRequest); + assertOK(getTokensResponse1); + final Map getTokensResponseMap1 = responseAsMap(getTokensResponse1); + assertThat(getTokensResponseMap1.get("service_account"), equalTo("elastic/fleet-server")); + assertThat(getTokensResponseMap1.get("count"), equalTo(1)); + assertThat(getTokensResponseMap1.get("tokens"), equalTo(org.elasticsearch.common.collect.Map.of())); + assertThat(getTokensResponseMap1.get("file_tokens"), + equalTo(org.elasticsearch.common.collect.Map.of("token1", org.elasticsearch.common.collect.Map.of()))); + + final Request createTokenRequest1 = new Request("POST", "_security/service/elastic/fleet-server/credential/token/api-token-1"); + final Response createTokenResponse1 = client().performRequest(createTokenRequest1); + assertOK(createTokenResponse1); + + final Request createTokenRequest2 = new Request("POST", "_security/service/elastic/fleet-server/credential/token/api-token-2"); + final Response createTokenResponse2 = client().performRequest(createTokenRequest2); + assertOK(createTokenResponse2); + + final Response getTokensResponse2 = client().performRequest(getTokensRequest); + assertOK(getTokensResponse2); + final Map getTokensResponseMap2 = responseAsMap(getTokensResponse2); + assertThat(getTokensResponseMap2.get("service_account"), equalTo("elastic/fleet-server")); + assertThat(getTokensResponseMap2.get("count"), equalTo(3)); + assertThat(getTokensResponseMap2.get("file_tokens"), + equalTo(org.elasticsearch.common.collect.Map.of("token1", org.elasticsearch.common.collect.Map.of()))); + assertThat(getTokensResponseMap2.get("tokens"), equalTo(org.elasticsearch.common.collect.Map.of( + "api-token-1", org.elasticsearch.common.collect.Map.of(), + "api-token-2", org.elasticsearch.common.collect.Map.of() + ))); + } + + public void testManageOwnApiKey() throws IOException { + final String token; + if (randomBoolean()) { + token = VALID_SERVICE_TOKEN; + } else { + final Request createTokenRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/api-token-42"); + final Response createTokenResponse = client().performRequest(createTokenRequest); + assertOK(createTokenResponse); + final Map createTokenResponseMap = responseAsMap(createTokenResponse); + assertThat(createTokenResponseMap.get("created"), is(true)); + @SuppressWarnings("unchecked") + final Map tokenMap = (Map) createTokenResponseMap.get("token"); + assertThat(tokenMap.get("name"), equalTo("api-token-42")); + token = tokenMap.get("value"); + } + final RequestOptions.Builder requestOptions = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + token); + + final Request createApiKeyRequest1 = new Request("PUT", "_security/api_key"); + if (randomBoolean()) { + createApiKeyRequest1.setJsonEntity("{\"name\":\"key-1\"}"); + } else { + createApiKeyRequest1.setJsonEntity("{\"name\":\"key-1\",\"role_descriptors\":{\"a\":{\"cluster\":[\"all\"]}}}"); + } + createApiKeyRequest1.setOptions(requestOptions); + final Response createApiKeyResponse1 = client().performRequest(createApiKeyRequest1); + assertOK(createApiKeyResponse1); + final String apiKeyId1 = (String) responseAsMap(createApiKeyResponse1).get("id"); + + assertApiKeys(apiKeyId1, "key-1", false, requestOptions); + + final Request invalidateApiKeysRequest = new Request("DELETE", "_security/api_key"); + invalidateApiKeysRequest.setJsonEntity("{\"ids\":[\"" + apiKeyId1 + "\"],\"owner\":true}"); + invalidateApiKeysRequest.setOptions(requestOptions); + final Response invalidateApiKeysResponse = client().performRequest(invalidateApiKeysRequest); + assertOK(invalidateApiKeysResponse); + final Map invalidateApiKeysResponseMap = responseAsMap(invalidateApiKeysResponse); + assertThat(invalidateApiKeysResponseMap.get("invalidated_api_keys"), equalTo(org.elasticsearch.common.collect.List.of(apiKeyId1))); + + assertApiKeys(apiKeyId1, "key-1", true, requestOptions); + } + + private void assertApiKeys(String apiKeyId, String name, boolean invalidated, + RequestOptions.Builder requestOptions) throws IOException { + final Request getApiKeysRequest = new Request("GET", "_security/api_key?owner=true"); + getApiKeysRequest.setOptions(requestOptions); + final Response getApiKeysResponse = client().performRequest(getApiKeysRequest); + assertOK(getApiKeysResponse); + final Map getApiKeysResponseMap = responseAsMap(getApiKeysResponse); + @SuppressWarnings("unchecked") + final List> apiKeys = (List>) getApiKeysResponseMap.get("api_keys"); + assertThat(apiKeys.size(), equalTo(1)); + + final Map apiKey = apiKeys.get(0); + assertThat(apiKey.get("id"), equalTo(apiKeyId)); + assertThat(apiKey.get("name"), equalTo(name)); + assertThat(apiKey.get("username"), equalTo("elastic/fleet-server")); + assertThat(apiKey.get("realm"), equalTo("service_account")); + assertThat(apiKey.get("invalidated"), is(invalidated)); + } +} diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens new file mode 100644 index 0000000000000..4595836b0ad6b --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens @@ -0,0 +1 @@ +elastic/fleet-server/token1:{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk= diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc new file mode 100644 index 0000000000000..d91e5653cdef9 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc @@ -0,0 +1,37 @@ += Keystore Details +This document details the steps used to create the certificate and keystore files in this directory. + +== Instructions on generating certificates + +The certificates in this directory have been generated using elasticsearch-certutil (8.0.0 SNAPSHOT) + +=== Certificates for security the HTTP server +[source,shell] +----------------------------------------------------------------------------------------------------------- +elasticsearch-certutil ca --pem --out=${PWD}/ca.zip --pass="ca-password" --days=3500 +unzip ca.zip +mv ca/ca.crt ./ca.crt +mv ca/ca.key ./ca.key + +rm ca.zip +rmdir ca +----------------------------------------------------------------------------------------------------------- + +[source,shell] +----------------------------------------------------------------------------------------------------------- +elasticsearch-certutil cert --pem --name=node --out=${PWD}/node.zip --pass="node-password" --days=3500 \ + --ca-cert=${PWD}/ca.crt --ca-key=${PWD}/ca.key --ca-pass="ca-password" \ + --dns=localhost --dns=localhost.localdomain --dns=localhost4 --dns=localhost4.localdomain4 --dns=localhost6 --dns=localhost6.localdomain6 \ + --ip=127.0.0.1 --ip=0:0:0:0:0:0:0:1 + +unzip node.zip +mv node/node.* ./ + +rm node.zip +rmdir node +----------------------------------------------------------------------------------------------------------- + +[source,shell] +----------------------------------------------------------------------------------------------------------- +keytool -importcert -file ca.crt -keystore ca.p12 -storetype PKCS12 -storepass "password" -alias ca +----------------------------------------------------------------------------------------------------------- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt new file mode 100644 index 0000000000000..ccfdadcab6d14 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSTCCAjGgAwIBAgIUG4Vi/zqBSBJT7DgRTFDQwh4ShlQwDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l +cmF0ZWQgQ0EwHhcNMjEwMzE4MDIyNjAyWhcNMzAxMDE3MDIyNjAyWjA0MTIwMAYD +VQQDEylFbGFzdGljIENlcnRpZmljYXRlIFRvb2wgQXV0b2dlbmVyYXRlZCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIfrBgvsv/i4v6bAtfZTCIBY ++OdhW6d2aF5LSPClruryqmp2vNWhGTEkcqe6EcFe+JRc+E+CnW0nXWslWf6kLxOJ +VR5kjuT7LZ1tGbm70joh5V1t79NXu+BC0B/ET6T/BDzjnrDlt+AsFmR+F348UftY +Y04NZRy+gRh9SxS0Y4riDGj0pWWJkPBK314JXf8rJe1RiYGfNl5OgAljGrs7sHAn +1AO2nEH8Ihad3V55dtMIMXHGQTWkIx+QK25cGpySB78CXR432BmRMieMHZ5z1ELL +A658Kco22HDmbNk4o51r/2AXs1fxcPTVZwK3n5tvC2hABXuILE7ck9A3LyGRZGMC +AwEAAaNTMFEwHQYDVR0OBBYEFNlY6G4x4gG5/lRF8fO6knZaOzzlMB8GA1UdIwQY +MBaAFNlY6G4x4gG5/lRF8fO6knZaOzzlMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAD4e1fOX00AT3bxXHyJd4tT6g40bxAmZhrtFDkoxX86Vp2bp +h+XfUfr54ziVzIbVynYMJ759mdB4BN8oZNTbOpmz/hNbz5skd2wIdAw/oZqAsOiW +l+OZLaaQYVfLesuBUJfxU7JvZeF0rB2F0ODc8BJz0Q6Mjbvj8fyCbSIQS01PjATN +0zeFQYuwJaQgTLVTU9jQYIbNBgCUuVmOW6IDF6QULtbCuH1Wtyr3u2I2nWfpyDhF +u7PY5Qh/O13rRy5o6NJofxaa3nU1PJalQzIA6ExA8ajol4ywiFtAyCVLYuJMKDt9 +HN0WWGAbhCPc/6i5KzNv6vW8EaWAOlAt2t1/7LU= +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key new file mode 100644 index 0000000000000..4438c4e59b247 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,AD07A96A73827285800BF6F4C8C37988 + +9F4L3SRxQaSkcmW72PaiPDDPNUW9zdoWy2VvSaKUp7cWCupUpF3gqvIwdpr/sHj5 +Jh4gfWCzASy2yb+Q/OAbeq2Nl5P7p6klDjBtDFVlLXmIearRiXBUgi7i55lic2nB +3zpUzBeXiqWxAiFTl1vhBB0DVexy0Ob7Hf3A7Zp669UQiMquplaGg+KtNVh2IxvJ +vZmV+danHJpTqd4CnC93J4l/4tH3/ZYHPydqe1a7Bhe0BwMurOqtoosuzF0BQMam +BcDVpyeRzg7C+ST1sZq+D/F1OpNvOOCE0hBjHg4NWdqyhiRLLwcbyEUutsyWo5zJ +QCnBiznVzeEobwFdglCLoe+fVFWVNe2fddX541kfcHRXozDvNbRMrkPwqWHzLLBc +bFn9PV3QSYoWE6Pee4/ibX4TYwe8yfxBBg5BpQQV+zjyBaXDQM6NNHMPxSE7YoD1 +TGAjQXwajse4uG0WRwOMgNHU9mzkMBLkv8s03PYmPXbnJkxd2jZSQoZ8FZrHQDXQ +oiMh6zMRDCiQRVrz7NwYN9uS5dwnj7fQDex5uyegIw7He57LuFJ92s7fqYAoaOtO +9QDRD5ky+q9+XN4T/3mOIaHTKNF5/kuN0eXH0vGVGWlNo2h+MBXGn+aA1p/97Cym +tZzmyAqDiXg9DhNMdHJor7DOQa9CCp5YxYYO5rzMa5ElvKIcOEmYkf1MTLq0Al/t +hYC5bL07aQ0sVhA+QW8kfxLkFT+u14rMlp6PJ9/KMLVBRoQWswwBMTBnocSwejkx +lZaGWjzpptQ3VqgSBOtEDjamItSFiZeN2ntwOckauVSRJZDig/q5yLgIlwrqxtDH +Sqh3u6JysIcBCcGg9U1q9AzxzFD8I4P8DwzUd56mbp3eR5iMvGsKcXbwlLvx/dSX +HVs0S7bEUr5WavmSIGwwrHtRO/l3STJNC1W7YxVKhBCxgz46DqADXbHuMvt8ZB13 +Zs94eEDA0vmPQnOilIG200V4OP3Uz8UP9HcNrhkLGuyCe+RIvv6NOwtq/O9YmazR +tmlcyrXEkvb5wubVg0zDlPpMBlYHGBEVl2vnVlNFHbsLkc55WahEbdpynnx3gYid +o4a5I/ywqaMou6ZTtOXZXc+0WuqjsLFAKmytZJtnktScGwJ+3JPWR51pi9j9q9W7 +oTnsyO4/a0nSZTNSGI2hxrmss5Y75bN/ydFuMhwd/GEiupKG40ZF+9hcGrqZRddM +uf0WoRvD5n611Bg8s9nwBMUjN7BFzu+a91s1W8LwwXUTZwkkyhkg/VUCKYbOH329 +Q6lZLb5nvvzEN/1HH/w0Bkl1jKBJSskw/R6zUGyviP1Sr3ZGkvUSvwXhrRHqI8MN +83t5AzZ6hivzy7rzCI/UsKoUx2/ef63TcvgLb/Vf85anuRR08Xcv/XIl775UvibQ +fAA0PE07sbYbO7vwRbv1bLhcPmA3wMsu0v/6Ohcv15uFFgUr/e9zhv5seP0tHdeR +ZKSbqlwfGRgp0smXPWJzIGG3g+lkadrfwTBuzgdjI8V/C+nEMk1eYy8SJd/CmfdG +IgZYMUWhc6GCcaq+eJ9VGVdgFkQU6aGTm4wNpmWPuDk/YDFo7ik48OrMvx67j1kz +-----END RSA PRIVATE KEY----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 new file mode 100644 index 0000000000000000000000000000000000000000..e79ddffd719810f6c0350656187ff7ffed054463 GIT binary patch literal 1130 zcmV-w1eN;Jo}qiO)CCI)8({H`xz`H;(?zfJVmE|Q;eGp);r=u}a?hFBVL1VGI|EM^ ztFmj<+le0l?bzO5>gUM;fjajg`K}`sJ zC2*RAFnWoOn&+_nr0{am5=s2GUWyRW=k)vb3@aICpEI>$%OZIr(Pg|#fWHcL2H?Z} zG|KDDg_Xv2K!r`I8n-FqDv((2+rNdZgNq>h?tR*cW_K?X6VVdaij52K``r%NZ{9Md z|MlOTzvEaM`evpe04Oq2=JQz$UAX&~rKz1A)FOlci+AwDWZ^Uw#|`_KDNY?#;^Wn} z&-;TfCKnwCe{1YjS2T_QdLOJ;#_6K>@E~-pT9^o2L5bp(ghJNr2iAqvn%Ta8p;A~ zpaKC0-;Z46ra7MDDOOAhvpNWQVwVq`?5~8tlt+DlAI3qLTl79^!x5V7ODtL8Y=2tU zFHWWII7h^!z%i1hpEi98OZOG=syx>E88588#%(V_Df6<8Uua{RI0;Tf(JA=z-V>9H zZp613T#7>}0v4$_9td*xnEe5cy42?oF@sCZd!0&6fG|EVAutIB1uG5%0vZJX1QZ_I wc?2M?&~#s@by@d(cYU0Z6?Oy^$7@pH@iy5giV3?nqOwq1mNN6t0s{etp#BdIjQ{`u literal 0 HcmV?d00001 diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt new file mode 100644 index 0000000000000..7b1bc7a5f5586 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDszCCApugAwIBAgIVAO2bFGZI6jJKeo1hea8Yc+RvY1J7MA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMB4XDTIxMDMxODAyMjYzMloXDTMwMTAxNzAyMjYzMlowDzENMAsG +A1UEAxMEbm9kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ7uDRl +d/wKlUkesl1jegzQcFw9po54Mt2O3UTOYBkFWg6amAIyA8Izkavkoh/kQZAR2gqa +O65jqO/rNRrNBlyX2x+IOm0XmDC1ZmHoOBIxaCZUGVqwkeHNxcb5TmVFbYAcRGOJ +b54v42SEarVoqJS9iQaGb7ScKTeQ7XWyPGImReVNwE7SJNWwuABTXMe9c6VtvZpY +xu1SX+gYVk7aWQ0p3ukHKJXrPfXYXSgozF3tKtFQvUrL1VjHEVWqWoBqjIbhl3X8 +eqkzxwC1y+8Zbp3Os9Y8PzHQ4etXG7UAPFRopy5MivlDxZ2u5DpVW/6Yy1B7i6Mp +9Leu2NPNZ7ul/iECAwEAAaOB4DCB3TAdBgNVHQ4EFgQUYVaPvntroOl+zfW5vDFg +Kvmmj1MwHwYDVR0jBBgwFoAU2VjobjHiAbn+VEXx87qSdlo7POUwgY8GA1UdEQSB +hzCBhIIJbG9jYWxob3N0ghdsb2NhbGhvc3Q2LmxvY2FsZG9tYWluNocEfwAAAYcQ +AAAAAAAAAAAAAAAAAAAAAYIKbG9jYWxob3N0NIIKbG9jYWxob3N0NoIVbG9jYWxo +b3N0LmxvY2FsZG9tYWlughdsb2NhbGhvc3Q0LmxvY2FsZG9tYWluNDAJBgNVHRME +AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAdP/Z/tDOWkM5Eob+6FwIJuM9Pe9+NOwUL ++0qrHNHDt5ITyUf/C/l6yfXgbkvoLRa9QefN0cxy0ru8ew3nUUn7US0EfWF0yrza +M8BwznKKh6cs4AiFUdDliBgyqAzYubcZ6G6Trm3Tdh334bAQKM7M1TOvZa8jwXXb +6T1PUs/2RCWE7nLxBooDTik86phUm65oVtTqoO0c4XbQzzTfRrF7Oy3kmqpKsrzv +UDB4G4TAfGyybdystyEqPPVX3KESV9PDcxpO01R2/BWi49E4YmdL4PitIA/v7iAk +SH0UISQNjDpncRz9mGrt8LrA+O2Canqiq3xXeHJEhU5/KPCPcsrm +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key new file mode 100644 index 0000000000000..3ec434b717a99 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,4A5CF28950363F663AA997154AC331F9 + +oHO/8oRnLXHTCljeqW90XBa/pDmLHUwRo82hy6732bSUTrXuBwUopuzcj6r8QzbQ +1ZyCbja0wwWaQ5TuNX3ehseiaBUKWgCLYYjd2IfFsfyFmvVAcPqnltyKMRvpLkFj +NeGyKFXmfxT3rmvzrmId4KkRYCHvH+j3RKfJ0wuhduzv9sH3xfmEe521l2F8Vukq +zVNMRPT9FHlSYhM1h26WpBlzx6Wq7EfP7KdyUtmIZ5/RFJjELG5rUyLgZHDqfKCy +LdNPpOuBdpYuBC+Oy97p2YuaFSLPkkKhiI4MG4MYsOnCmEFBNup9OhF3U/t/ffXh +knTjXh2fX7h8RJ9pH/8czG+O6cZoe5O/1/Ympo+ghS7QYDUtDrNS5M4MI+eP+WiA +X3cev3VkugDw4dDSPq3i3E0oCRZesMpst2W6AtVcpa5EWRM75PVuUws0XY/V/ca0 +CdUO6CPVIAAT3urmJWC1reiNhkEMDrskOL1PnsrseGvOmCLava9xYjiAS6JGawm/ +kWN3unJ6BwlU0NkIEbj8OGHdiKAjNWr0HLR34Xa2gqup5pGVD8EoC20ZPjeDXZ2j +oEfuLo2ZaF5CWDt0CEcdN7v/JtXC9QJjf0BAMHKiULhPzv9rNfqj6xZKkNxgVrW/ +D2/Jpyn5qt6BDiyzG0jaO7AzIk3BTBksdf+5myc5/0NA+kdC9aKZKmeLAazCAK1G +CwtfTs1xF4tMj1P+GRD4DOwypml1OK528BSl+Ydubt2uc37hRsA2EctEEjy+vy2r +pR0akSVs2a4d00p26lWt5RP/h85KJdWwNj/YwRmRxWWMpNd/C4NrGgB5Ehs8CHFk +uQZOaAKXWuy/bPGKG+JdXqEM5CiasNqoJn0UBle2dOpG08Ezb19vHFgNSOuvrxEv +oxkklXzyw+JMyskmD67MxQBsHcxW4g+501OMhIb2J36LNsOMQxzjIpS2jia/P1lh +9R4WohPxKf7Hi0Ui6oQRC7i2USmisuHIlVAmv14AjiISJdhXVOFtu+hVWrCHqHEg +GWRj560G1WwT5EHZr4KN+6IRX6mCKJAO1XjSz5rPfDpet5OQGIr7N+lJwWE03kJs +6Pd8K0OYW+2rbwqFd4YugF18HQlA1T5aok4fj2P+BTOuCNfvf0ZZXFeBn45zgtZI +G/puduRwRRyUzB+XTzhN8o6dfuBjasq6U0/ZFDRKKJnAOAq/fmVxr51+zKvZ0T5B +NSPbD9wUdnABqGCR+y9AL63QP0iVrkLlKzjgUYdlb1lw4TnmLGadmfYaZoOtWH2c +FOucH3VVfinY7Q9EE5/EF5EHeG3pe3I3UHXTbAvcxvuhCByFZd6qe3Vz4AGcQLoT +ProWJzmjeElfziX4e4Ol6tNSAxwL+vhjn4KmvF4mFx6n+QMAyp8lEmPsYgnsT/n9 +pkdnk0VdLGQmp8eKExvvDfiDTagDnh6wr7Nys1VLBADIthsRW4Gdft02q3tFOyae +WpeZent5x28yRPbNgDtoStjqc0yQPdXVFuAsLzA6NT8ujlOhJCnmiPYOurGis0Ch +hQLV+kr5EybbUHGjMB01elqTXy2VTMEqQ/7TQdsy6vIDYeBq5t491t9P/TeeS5Om +-----END RSA PRIVATE KEY----- diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java index f0fd9ba857c30..6ed459d97e18c 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java @@ -225,6 +225,10 @@ protected String configOperatorUsers() { return SECURITY_DEFAULT_SETTINGS.configOperatorUsers(); } + protected String configServiceTokens() { + return SECURITY_DEFAULT_SETTINGS.configServiceTokens(); + } + /** * Allows to override the node client username */ @@ -272,6 +276,11 @@ protected String configOperatorUsers() { return SecuritySingleNodeTestCase.this.configOperatorUsers(); } + @Override + protected String configServiceTokens() { + return SecuritySingleNodeTestCase.this.configServiceTokens(); + } + @Override protected String nodeClientUsername() { return SecuritySingleNodeTestCase.this.nodeClientUsername(); diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java new file mode 100644 index 0000000000000..3c4f40a84ee03 --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java @@ -0,0 +1,73 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.Version; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.node.Node; +import org.elasticsearch.test.SecuritySingleNodeTestCase; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateAction; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateRequest; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.User; + +import static org.elasticsearch.test.SecuritySettingsSource.addSSLSettingsForNodePEMFiles; +import static org.hamcrest.Matchers.equalTo; + +public class ServiceAccountSingleNodeTests extends SecuritySingleNodeTestCase { + + private static final String BEARER_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"; + + @Override + protected Settings nodeSettings() { + Settings.Builder builder = Settings.builder().put(super.nodeSettings()); + addSSLSettingsForNodePEMFiles(builder, "xpack.security.http.", true); + builder.put("xpack.security.http.ssl.enabled", true); + return builder.build(); + } + + @Override + protected boolean addMockHttpTransport() { + return false; // enable http + } + + @Override + protected boolean transportSSLEnabled() { + return true; + } + + @Override + protected String configServiceTokens() { + return super.configServiceTokens() + + "elastic/fleet-server/token1:" + + "{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk="; + } + + public void testAuthenticateWithServiceFileToken() { + final AuthenticateRequest authenticateRequest = new AuthenticateRequest("elastic/fleet-server"); + final AuthenticateResponse authenticateResponse = + createServiceAccountClient().execute(AuthenticateAction.INSTANCE, authenticateRequest).actionGet(); + final String nodeName = node().settings().get(Node.NODE_NAME_SETTING.getKey()); + assertThat(authenticateResponse.authentication(), equalTo( + new Authentication( + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, "Service account - elastic/fleet-server", null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), true), + new Authentication.RealmRef("service_account", "service_account", nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", "token1") + ) + )); + } + + private Client createServiceAccountClient() { + return client().filterWithHeader(org.elasticsearch.common.collect.Map.of("Authorization", "Bearer " + BEARER_TOKEN)); + } +} diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens new file mode 100755 index 0000000000000..c9cdbd78bc8f0 --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens @@ -0,0 +1,11 @@ +#!/bin/bash + +# 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. + +ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool \ + ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \ + "`dirname "$0"`"/elasticsearch-cli \ + "$@" diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat new file mode 100644 index 0000000000000..6ca1260c2a6ab --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-service-tokens.bat @@ -0,0 +1,20 @@ +@echo off + +rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +rem or more contributor license agreements. Licensed under the Elastic License +rem 2.0; you may not use this file except in compliance with the Elastic License +rem 2.0. + +setlocal enabledelayedexpansion +setlocal enableextensions + +set ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.service.FileTokensTool +set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env +call "%~dp0elasticsearch-cli.bat" ^ + %%* ^ + || goto exit + +endlocal +endlocal +:exit +exit /b %ERRORLEVEL% diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 308f283a8fbca..71295b9f55a55 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -112,6 +112,8 @@ import org.elasticsearch.xpack.core.security.action.saml.SamlLogoutAction; import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationAction; import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataAction; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensAction; import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction; import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction; import org.elasticsearch.xpack.core.security.action.token.RefreshTokenAction; @@ -179,6 +181,8 @@ import org.elasticsearch.xpack.security.action.saml.TransportSamlLogoutAction; import org.elasticsearch.xpack.security.action.saml.TransportSamlPrepareAuthenticationAction; import org.elasticsearch.xpack.security.action.saml.TransportSamlSpMetadataAction; +import org.elasticsearch.xpack.security.action.service.TransportCreateServiceAccountTokenAction; +import org.elasticsearch.xpack.security.action.service.TransportGetServiceAccountTokensAction; import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction; import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction; import org.elasticsearch.xpack.security.action.token.TransportRefreshTokenAction; @@ -200,7 +204,13 @@ import org.elasticsearch.xpack.security.authc.TokenService; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.authc.service.CachingServiceAccountsTokenStore; +import org.elasticsearch.xpack.security.authc.service.FileServiceAccountsTokenStore; +import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountsTokenStore; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; +import org.elasticsearch.xpack.security.authc.service.CompositeServiceAccountsTokenStore; import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; import org.elasticsearch.xpack.security.authz.AuthorizationService; import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener; @@ -253,6 +263,8 @@ import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction; import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction; import org.elasticsearch.xpack.security.rest.action.saml.RestSamlSpMetadataAction; +import org.elasticsearch.xpack.security.rest.action.service.RestCreateServiceAccountTokenAction; +import org.elasticsearch.xpack.security.rest.action.service.RestGetServiceAccountTokensAction; import org.elasticsearch.xpack.security.rest.action.user.RestChangePasswordAction; import org.elasticsearch.xpack.security.rest.action.user.RestDeleteUserAction; import org.elasticsearch.xpack.security.rest.action.user.RestGetUserPrivilegesAction; @@ -340,6 +352,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin, private final SetOnce dlsBitsetCache = new SetOnce<>(); private final SetOnce> bootstrapChecks = new SetOnce<>(); private final List securityExtensions = new ArrayList<>(); + private final SetOnce transportReference = new SetOnce<>(); public Security(Settings settings, final Path configPath) { this(settings, configPath, Collections.emptyList()); @@ -526,9 +539,25 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, getLicenseState(), securityIndex.get(), clusterService, cacheInvalidatorRegistry, threadPool); components.add(apiKeyService); + + final HttpTlsRuntimeCheck httpTlsRuntimeCheck = new HttpTlsRuntimeCheck(settings, transportReference); + components.add(httpTlsRuntimeCheck); + + final IndexServiceAccountsTokenStore indexServiceAccountsTokenStore = new IndexServiceAccountsTokenStore( + settings, threadPool, getClock(), client, securityIndex.get(), clusterService, cacheInvalidatorRegistry); + components.add(indexServiceAccountsTokenStore); + + final FileServiceAccountsTokenStore fileServiceAccountsTokenStore = + new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool); + + final ServiceAccountService serviceAccountService = new ServiceAccountService(new CompositeServiceAccountsTokenStore( + org.elasticsearch.common.collect.List.of(fileServiceAccountsTokenStore, indexServiceAccountsTokenStore), + threadPool.getThreadContext()), httpTlsRuntimeCheck); + components.add(serviceAccountService); + final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService, - dlsBitsetCache.get(), new DeprecationRoleDescriptorConsumer(clusterService, threadPool)); + serviceAccountService, dlsBitsetCache.get(), new DeprecationRoleDescriptorConsumer(clusterService, threadPool)); securityIndex.get().addIndexStateListener(allRolesStore::onSecurityIndexStateChange); // to keep things simple, just invalidate all cached entries on license change. this happens so rarely that the impact should be @@ -547,7 +576,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste operatorPrivilegesService = OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE; } authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool, - anonymousUser, tokenService, apiKeyService, operatorPrivilegesService)); + anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService)); components.add(authcService.get()); securityIndex.get().addIndexStateListener(authcService.get()::onSecurityIndexStateChange); @@ -747,6 +776,9 @@ public static List> getSettings(boolean transportClientMode, List(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class), new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class), new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class), - new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class) + new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class), + new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class), + new ActionHandler<>(GetServiceAccountTokensAction.INSTANCE, TransportGetServiceAccountTokensAction.class) ); } @@ -939,7 +973,9 @@ public List getRestHandlers(Settings settings, RestController restC new RestGrantApiKeyAction(settings, getLicenseState()), new RestInvalidateApiKeyAction(settings, getLicenseState()), new RestGetApiKeyAction(settings, getLicenseState()), - new RestDelegatePkiAuthenticationAction(settings, getLicenseState()) + new RestDelegatePkiAuthenticationAction(settings, getLicenseState()), + new RestCreateServiceAccountTokenAction(settings, getLicenseState()), + new RestGetServiceAccountTokensAction(settings, getLicenseState()) ); } @@ -1046,12 +1082,20 @@ public Map> getTransports(Settings settings, ThreadP } IPFilter ipFilter = this.ipFilter.get(); + Map> transports = new HashMap<>(); - transports.put(SecurityField.NAME4, () -> new SecurityNetty4ServerTransport(settings, Version.CURRENT, threadPool, - networkService, pageCacheRecycler, namedWriteableRegistry, circuitBreakerService, ipFilter, getSslService(), - getNettySharedGroupFactory(settings))); - transports.put(SecurityField.NIO, () -> new SecurityNioTransport(settings, Version.CURRENT, threadPool, networkService, - pageCacheRecycler, namedWriteableRegistry, circuitBreakerService, ipFilter, getSslService(), getNioGroupFactory(settings))); + transports.put(SecurityField.NAME4, () -> { + transportReference.set(new SecurityNetty4ServerTransport(settings, Version.CURRENT, threadPool, + networkService, pageCacheRecycler, namedWriteableRegistry, circuitBreakerService, ipFilter, getSslService(), + getNettySharedGroupFactory(settings))); + return transportReference.get(); + }); + transports.put(SecurityField.NIO, () -> { + transportReference.set(new SecurityNioTransport(settings, Version.CURRENT, threadPool, networkService, + pageCacheRecycler, namedWriteableRegistry, circuitBreakerService, ipFilter, getSslService(), + getNioGroupFactory(settings))); + return transportReference.get(); + }); return Collections.unmodifiableMap(transports); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenAction.java new file mode 100644 index 0000000000000..d7dfc9a215b46 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenAction.java @@ -0,0 +1,54 @@ +/* + * 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.security.action.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountsTokenStore; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; + +public class TransportCreateServiceAccountTokenAction + extends HandledTransportAction { + + private final IndexServiceAccountsTokenStore indexServiceAccountsTokenStore; + private final SecurityContext securityContext; + private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; + + @Inject + public TransportCreateServiceAccountTokenAction(TransportService transportService, ActionFilters actionFilters, + IndexServiceAccountsTokenStore indexServiceAccountsTokenStore, + SecurityContext securityContext, + HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + super(CreateServiceAccountTokenAction.NAME, transportService, actionFilters, CreateServiceAccountTokenRequest::new); + this.indexServiceAccountsTokenStore = indexServiceAccountsTokenStore; + this.securityContext = securityContext; + this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; + } + + @Override + protected void doExecute(Task task, CreateServiceAccountTokenRequest request, + ActionListener listener) { + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "create service account token", () -> { + final Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + listener.onFailure(new IllegalStateException("authentication is required")); + } else { + indexServiceAccountsTokenStore.createToken(authentication, request, listener); + } + }); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensAction.java new file mode 100644 index 0000000000000..2b3b1ce3a5b40 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensAction.java @@ -0,0 +1,50 @@ +/* + * 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.security.action.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.node.Node; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensResponse; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; + +public class TransportGetServiceAccountTokensAction + extends HandledTransportAction { + + private final ServiceAccountService serviceAccountService; + private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; + private final String nodeName; + + @Inject + public TransportGetServiceAccountTokensAction(TransportService transportService, ActionFilters actionFilters, + Settings settings, + ServiceAccountService serviceAccountService, + HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + super(GetServiceAccountTokensAction.NAME, transportService, actionFilters, GetServiceAccountTokensRequest::new); + this.nodeName = Node.NODE_NAME_SETTING.get(settings); + this.serviceAccountService = serviceAccountService; + this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; + } + + @Override + protected void doExecute(Task task, GetServiceAccountTokensRequest request, ActionListener listener) { + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "get service account tokens", () -> { + final ServiceAccountId accountId = new ServiceAccountId(request.getNamespace(), request.getServiceName()); + serviceAccountService.findTokensFor(accountId, nodeName, listener); + }); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index a2112ba717570..78a43ca1da861 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -743,6 +743,17 @@ static ApiKeyCredentials getCredentialsFromHeader(ThreadContext threadContext) { return null; } + public static boolean isApiKeyAuthentication(Authentication authentication) { + final Authentication.AuthenticationType authType = authentication.getAuthenticationType(); + if (Authentication.AuthenticationType.API_KEY == authType) { + assert API_KEY_REALM_TYPE.equals(authentication.getAuthenticatedBy().getType()) + : "API key authentication must have API key realm type"; + return true; + } else { + return false; + } + } + // Protected instance method so this can be mocked protected void verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials, ActionListener listener) { threadPool.executor(SECURITY_CRYPTO_THREAD_POOL_NAME).execute(ActionRunnable.supply(listener, () -> { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 6fbf64c5376eb..0e5d05a298378 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; @@ -46,6 +47,8 @@ import org.elasticsearch.xpack.security.audit.AuditTrail; import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountToken; import org.elasticsearch.xpack.security.authc.support.RealmUserLookup; import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -88,6 +91,7 @@ public class AuthenticationService { private final Cache lastSuccessfulAuthCache; private final AtomicLong numInvalidation = new AtomicLong(); private final ApiKeyService apiKeyService; + private final ServiceAccountService serviceAccountService; private final OperatorPrivilegesService operatorPrivilegesService; private final boolean runAsEnabled; private final boolean isAnonymousUserEnabled; @@ -96,6 +100,7 @@ public class AuthenticationService { public AuthenticationService(Settings settings, Realms realms, AuditTrailService auditTrailService, AuthenticationFailureHandler failureHandler, ThreadPool threadPool, AnonymousUser anonymousUser, TokenService tokenService, ApiKeyService apiKeyService, + ServiceAccountService serviceAccountService, OperatorPrivilegesService operatorPrivilegesService) { this.nodeName = Node.NODE_NAME_SETTING.get(settings); this.realms = realms; @@ -115,6 +120,7 @@ public AuthenticationService(Settings settings, Realms realms, AuditTrailService this.lastSuccessfulAuthCache = null; } this.apiKeyService = apiKeyService; + this.serviceAccountService = serviceAccountService; this.operatorPrivilegesService = operatorPrivilegesService; this.authenticationSerializer = new AuthenticationContextSerializer(); } @@ -329,27 +335,44 @@ private void authenticateAsync() { logger.trace("Found existing authentication [{}] in request [{}]", authentication, request); listener.onResponse(authentication); } else { - tokenService.getAndValidateToken(threadContext, ActionListener.wrap(userToken -> { - if (userToken != null) { - writeAuthToContext(userToken.getAuthentication()); - } else { - checkForApiKey(); - } - }, e -> { - logger.debug(new ParameterizedMessage("Failed to validate token authentication for request [{}]", request), e); - if (e instanceof ElasticsearchSecurityException && - tokenService.isExpiredTokenException((ElasticsearchSecurityException) e) == false) { - // intentionally ignore the returned exception; we call this primarily - // for the auditing as we already have a purpose built exception - request.tamperedRequest(); - } - listener.onFailure(e); - })); + checkForBearerToken(); } }); } } + private void checkForBearerToken() { + final SecureString bearerString = tokenService.extractBearerTokenFromHeader(threadContext); + final ServiceAccountToken serviceAccountToken = ServiceAccountService.tryParseToken(bearerString); + if (serviceAccountToken != null) { + serviceAccountService.authenticateToken(serviceAccountToken, nodeName, ActionListener.wrap(authentication -> { + assert authentication != null : "service account authenticate should return either authentication or call onFailure"; + this.authenticatedBy = authentication.getAuthenticatedBy(); + writeAuthToContext(authentication); + }, e -> { + logger.debug(new ParameterizedMessage("Failed to validate service account token for request [{}]", request), e); + listener.onFailure(request.exceptionProcessingRequest(e, serviceAccountToken)); + })); + } else { + tokenService.tryAuthenticateToken(bearerString, ActionListener.wrap(userToken -> { + if (userToken != null) { + writeAuthToContext(userToken.getAuthentication()); + } else { + checkForApiKey(); + } + }, e -> { + logger.debug(new ParameterizedMessage("Failed to validate token authentication for request [{}]", request), e); + if (e instanceof ElasticsearchSecurityException + && false == tokenService.isExpiredTokenException((ElasticsearchSecurityException) e)) { + // intentionally ignore the returned exception; we call this primarily + // for the auditing as we already have a purpose built exception + request.tamperedRequest(); + } + listener.onFailure(e); + })); + } + } + private void checkForApiKey() { apiKeyService.authenticateWithApiKeyIfPresent(threadContext, ActionListener.wrap(authResult -> { if (authResult.isAuthenticated()) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index a4bfbe31874f9..6e32bd58c8a72 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -203,6 +203,7 @@ public final class TokenService { static final Version VERSION_TOKENS_INDEX_INTRODUCED = Version.V_7_2_0; static final Version VERSION_ACCESS_TOKENS_AS_UUIDS = Version.V_7_2_0; static final Version VERSION_MULTIPLE_CONCURRENT_REFRESHES = Version.V_7_2_0; + private static final Logger logger = LogManager.getLogger(TokenService.class); private final SecureRandom secureRandom = new SecureRandom(); @@ -382,30 +383,12 @@ public static String hashTokenString(String accessTokenString) { } /** - * Looks in the context to see if the request provided a header with a user token and if so the - * token is validated, which might include authenticated decryption and verification that the token - * has not been revoked or is expired. + * If the token is non-null, then it is validated, which might include authenticated decryption and + * verification that the token has not been revoked or is expired. */ - void getAndValidateToken(ThreadContext ctx, ActionListener listener) { - if (isEnabled()) { - final String token = getFromHeader(ctx); - if (token == null) { - listener.onResponse(null); - } else { - decodeToken(token, ActionListener.wrap(userToken -> { - if (userToken != null) { - checkIfTokenIsValid(userToken, listener); - } else { - listener.onResponse(null); - } - }, e -> { - if (isShardNotAvailableException(e)) { - listener.onResponse(null); - } else { - listener.onFailure(e); - } - })); - } + void tryAuthenticateToken(SecureString token, ActionListener listener) { + if (isEnabled() && token != null) { + decodeAndValidateToken(token, listener); } else { listener.onResponse(null); } @@ -419,29 +402,13 @@ void getAndValidateToken(ThreadContext ctx, ActionListener listener) * {@code null} authentication object. */ public void authenticateToken(SecureString tokenString, ActionListener listener) { - ensureEnabled(); - decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { - if (userToken != null) { - checkIfTokenIsValid(userToken, ActionListener.wrap( - token -> { - if (token == null) { - // Typically this means that the index is unavailable, so _probably_ the token is invalid but the only - // this we can say for certain is that we couldn't validate it. The logs will be more explicit. - listener.onFailure(new IllegalArgumentException("Cannot validate access token")); - } else { - listener.onResponse(token.getAuthentication()); - } - }, - listener::onFailure - )); - } else { - listener.onFailure(new IllegalArgumentException("Cannot decode access token")); - } - }, e -> { - if (isShardNotAvailableException(e)) { - listener.onResponse(null); + decodeAndValidateToken(tokenString, listener.map(token -> { + if (token == null) { + // Typically this means that the index is unavailable, so _probably_ the token is invalid but the only + // this we can say for certain is that we couldn't validate it. The logs will be more explicit. + throw new IllegalArgumentException("Cannot validate access token"); } else { - listener.onFailure(e); + return token.getAuthentication(); } })); } @@ -516,6 +483,23 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action } } + private void decodeAndValidateToken(SecureString tokenString, ActionListener listener) { + ensureEnabled(); + decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { + if (userToken != null) { + checkIfTokenIsValid(userToken, listener); + } else { + listener.onResponse(null); + } + }, e -> { + if (isShardNotAvailableException(e)) { + listener.onResponse(null); + } else { + listener.onFailure(e); + } + })); + } + /** * If needed, for tokens that were created in a pre {@code #VERSION_ACCESS_TOKENS_UUIDS} cluster, it asynchronously decodes the token to * get the token document id. The process for this is asynchronous as we may need to compute a key, which can be computationally @@ -1715,24 +1699,24 @@ private void maybeStartTokenRemover() { * Gets the token from the Authorization header if the header begins with * Bearer */ - private String getFromHeader(ThreadContext threadContext) { + public SecureString extractBearerTokenFromHeader(ThreadContext threadContext) { String header = threadContext.getHeader("Authorization"); if (Strings.hasText(header) && header.regionMatches(true, 0, "Bearer ", 0, "Bearer ".length()) && header.length() > "Bearer ".length()) { - return header.substring("Bearer ".length()); + char[] chars = new char[header.length() - "Bearer ".length()]; + header.getChars("Bearer ".length(), header.length(), chars, 0); + return new SecureString(chars); } return null; } String prependVersionAndEncodeAccessToken(Version version, String accessToken) throws IOException, GeneralSecurityException { if (version.onOrAfter(VERSION_ACCESS_TOKENS_AS_UUIDS)) { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(MINIMUM_BASE64_BYTES); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput(MINIMUM_BASE64_BYTES)) { out.setVersion(version); Version.writeVersion(version, out); out.writeString(accessToken); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); } } else { // we know that the minimum length is larger than the default of the ByteArrayOutputStream so set the size to this explicitly @@ -1760,13 +1744,12 @@ String prependVersionAndEncodeAccessToken(Version version, String accessToken) t } public static String prependVersionAndEncodeRefreshToken(Version version, String payload) { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput()) { out.setVersion(version); Version.writeVersion(version, out); out.writeString(payload); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); + } catch (IOException e) { throw new RuntimeException("Unexpected exception when working with small in-memory streams", e); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java index 7aad0fcea978e..873bf021dff77 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/file/FileUserPasswdStore.java @@ -16,7 +16,6 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.Maps; import org.elasticsearch.env.Environment; -import org.elasticsearch.watcher.FileChangesListener; import org.elasticsearch.watcher.FileWatcher; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.core.XPackPlugin; @@ -28,6 +27,7 @@ import org.elasticsearch.xpack.core.security.support.Validation; import org.elasticsearch.xpack.core.security.support.Validation.Users; import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.support.FileReloadListener; import org.elasticsearch.xpack.security.support.SecurityFiles; import java.io.IOException; @@ -62,7 +62,7 @@ public FileUserPasswdStore(RealmConfig config, ResourceWatcherService watcherSer users = parseFileLenient(file, logger, settings); listeners = new CopyOnWriteArrayList<>(Collections.singletonList(listener)); FileWatcher watcher = new FileWatcher(file.getParent()); - watcher.addListener(new FileListener()); + watcher.addListener(new FileReloadListener(file, this::tryReload)); try { watcherService.add(watcher, ResourceWatcherService.Frequency.HIGH); } catch (IOException e) { @@ -179,28 +179,13 @@ void notifyRefresh() { listeners.forEach(Runnable::run); } - private class FileListener implements FileChangesListener { - @Override - public void onFileCreated(Path file) { - onFileChanged(file); - } - - @Override - public void onFileDeleted(Path file) { - onFileChanged(file); - } - - @Override - public void onFileChanged(Path file) { - if (file.equals(FileUserPasswdStore.this.file)) { - final Map previousUsers = users; - users = parseFileLenient(file, logger, settings); + private void tryReload() { + final Map previousUsers = users; + users = parseFileLenient(file, logger, settings); - if (Maps.deepEquals(previousUsers, users) == false) { - logger.info("users file [{}] changed. updating users... )", file.toAbsolutePath()); - notifyRefresh(); - } - } + if (Maps.deepEquals(previousUsers, users) == false) { + logger.info("users file [{}] changed. updating users...", file.toAbsolutePath()); + notifyRefresh(); } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..cb6f97752f086 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java @@ -0,0 +1,156 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.cache.CacheBuilder; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.concurrent.ListenableFuture; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; + +import java.util.Collection; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +public abstract class CachingServiceAccountsTokenStore implements ServiceAccountsTokenStore, CacheInvalidatorRegistry.CacheInvalidator { + + private static final Logger logger = LogManager.getLogger(CachingServiceAccountsTokenStore.class); + + public static final Setting CACHE_HASH_ALGO_SETTING = Setting.simpleString("xpack.security.authc.service_token.cache.hash_algo", + "ssha256", Setting.Property.NodeScope); + + public static final Setting CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authc.service_token.cache.ttl", + TimeValue.timeValueMinutes(20), Setting.Property.NodeScope); + public static final Setting CACHE_MAX_TOKENS_SETTING = Setting.intSetting( + "xpack.security.authc.service_token.cache.max_tokens", 100_000, Setting.Property.NodeScope); + + private final Settings settings; + private final ThreadPool threadPool; + private final Cache> cache; + private final Hasher hasher; + + CachingServiceAccountsTokenStore(Settings settings, ThreadPool threadPool) { + this.settings = settings; + this.threadPool = threadPool; + final TimeValue ttl = CACHE_TTL_SETTING.get(settings); + if (ttl.getNanos() > 0) { + cache = CacheBuilder.>builder() + .setExpireAfterWrite(ttl) + .setMaximumWeight(CACHE_MAX_TOKENS_SETTING.get(settings)) + .build(); + } else { + cache = null; + } + hasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings)); + } + + @Override + public void authenticate(ServiceAccountToken token, ActionListener listener) { + try { + if (cache == null) { + doAuthenticate(token, listener); + } else { + authenticateWithCache(token, listener); + } + } catch (Exception e) { + listener.onFailure(e); + } + } + + private void authenticateWithCache(ServiceAccountToken token, ActionListener listener) { + assert cache != null; + try { + final AtomicBoolean valueAlreadyInCache = new AtomicBoolean(true); + final ListenableFuture listenableCacheEntry = cache.computeIfAbsent(token.getQualifiedName(), k -> { + valueAlreadyInCache.set(false); + return new ListenableFuture<>(); + }); + if (valueAlreadyInCache.get()) { + listenableCacheEntry.addListener(ActionListener.wrap(result -> { + if (result.success) { + listener.onResponse(result.verify(token)); + } else if (result.verify(token)) { + // same wrong token + listener.onResponse(false); + } else { + cache.invalidate(token.getQualifiedName(), listenableCacheEntry); + authenticateWithCache(token, listener); + } + }, listener::onFailure), threadPool.generic(), threadPool.getThreadContext()); + } else { + doAuthenticate(token, ActionListener.wrap(success -> { + logger.trace("cache service token [{}] authentication result", token.getQualifiedName()); + listenableCacheEntry.onResponse(new CachedResult(hasher, success, token)); + listener.onResponse(success); + }, e -> { + // In case of failure, evict the cache entry and notify all listeners + cache.invalidate(token.getQualifiedName(), listenableCacheEntry); + listenableCacheEntry.onFailure(e); + listener.onFailure(e); + })); + } + } catch (final ExecutionException e) { + listener.onFailure(e); + } + } + + @Override + public final void invalidate(Collection qualifiedTokenNames) { + if (cache != null) { + logger.trace("invalidating cache for service token [{}]", + Strings.collectionToCommaDelimitedString(qualifiedTokenNames)); + qualifiedTokenNames.forEach(cache::invalidate); + } + } + + @Override + public final void invalidateAll() { + if (cache != null) { + logger.trace("invalidating cache for all service tokens"); + cache.invalidateAll(); + } + } + + protected Settings getSettings() { + return settings; + } + + protected ThreadPool getThreadPool() { + return threadPool; + } + + abstract void doAuthenticate(ServiceAccountToken token, ActionListener listener); + + // package private for testing + Cache> getCache() { + return cache; + } + + static class CachedResult { + + private final boolean success; + private final char[] hash; + + private CachedResult(Hasher hasher, boolean success, ServiceAccountToken token) { + this.success = success; + this.hash = hasher.hash(token.getSecret()); + } + + private boolean verify(ServiceAccountToken token) { + return hash != null && Hasher.verifyHash(token.getSecret(), hash); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..149f28e546cdc --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStore.java @@ -0,0 +1,103 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.xpack.core.common.IteratingActionListener; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +public final class CompositeServiceAccountsTokenStore implements ServiceAccountsTokenStore { + + private static final Logger logger = + LogManager.getLogger(org.elasticsearch.xpack.security.authc.service.CompositeServiceAccountsTokenStore.class); + + private final ThreadContext threadContext; + private final List stores; + + public CompositeServiceAccountsTokenStore( + List stores, ThreadContext threadContext) { + this.stores = stores; + this.threadContext = threadContext; + } + + @Override + public void authenticate(ServiceAccountToken token, ActionListener listener) { + // TODO: optimize store order based on auth result? + final IteratingActionListener authenticatingListener = new IteratingActionListener<>( + listener, + (store, successListener) -> store.authenticate(token, successListener), + stores, + threadContext, + Function.identity(), + success -> Boolean.FALSE == success); + try { + authenticatingListener.run(); + } catch (Exception e) { + logger.debug(new ParameterizedMessage("authentication of service token [{}] failed", token.getQualifiedName()), e); + listener.onFailure(e); + } + } + + @Override + public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + final CollectingActionListener collector = new CollectingActionListener(accountId, listener); + try { + collector.run(); + } catch (Exception e) { + listener.onFailure(e); + } + } + + class CollectingActionListener implements ActionListener>, Runnable { + private final ActionListener> delegate; + private final ServiceAccountId accountId; + private final List result = new ArrayList<>(); + private int position = 0; + + CollectingActionListener(ServiceAccountId accountId, ActionListener> delegate) { + this.delegate = delegate; + this.accountId = accountId; + } + + @Override + public void run() { + if (stores.isEmpty()) { + delegate.onResponse(org.elasticsearch.common.collect.List.of()); + } else if (position < 0 || position >= stores.size()) { + onFailure(new IllegalArgumentException("invalid position [" + position + "]. List size [" + stores.size() + "]")); + } else { + stores.get(position++).findTokensFor(accountId, this); + } + } + + @Override + public void onResponse(Collection response) { + result.addAll(response); + if (position == stores.size()) { + delegate.onResponse(org.elasticsearch.common.collect.List.copyOf(result)); + } else { + stores.get(position++).findTokensFor(accountId, this); + } + } + + @Override + public void onFailure(Exception e) { + delegate.onFailure(e); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java new file mode 100644 index 0000000000000..28923627b1fcc --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java @@ -0,0 +1,78 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.user.User; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +final class ElasticServiceAccounts { + + static final String NAMESPACE = "elastic"; + + private static final ServiceAccount FLEET_ACCOUNT = new ElasticServiceAccount("fleet-server", + new RoleDescriptor( + NAMESPACE + "/fleet-server", + new String[]{"monitor", "manage_own_api_key"}, + new RoleDescriptor.IndicesPrivileges[]{ + RoleDescriptor.IndicesPrivileges + .builder() + .indices("logs-*", "metrics-*", "traces-*") + .privileges("write", "create_index", "auto_configure") + .build() + }, + null, + null, + null, + null, + null + )); + + static final Map ACCOUNTS = org.elasticsearch.common.collect.List.of(FLEET_ACCOUNT).stream() + .collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));; + + private ElasticServiceAccounts() {} + + static class ElasticServiceAccount implements ServiceAccount { + private final ServiceAccountId id; + private final RoleDescriptor roleDescriptor; + private final User user; + + ElasticServiceAccount(String serviceName, RoleDescriptor roleDescriptor) { + this.id = new ServiceAccountId(NAMESPACE, serviceName); + this.roleDescriptor = Objects.requireNonNull(roleDescriptor, "Role descriptor cannot be null"); + if (roleDescriptor.getName().equals(id.asPrincipal()) == false) { + throw new IllegalArgumentException("the provided role descriptor [" + roleDescriptor.getName() + + "] must have the same name as the service account [" + id.asPrincipal() + "]"); + } + this.user = new User(id.asPrincipal(), Strings.EMPTY_ARRAY, "Service account - " + id, null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), + true); + } + + @Override + public ServiceAccountId id() { + return id; + } + + @Override + public RoleDescriptor roleDescriptor() { + return roleDescriptor; + } + + @Override + public User asUser() { + return user; + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..f7cd3606e0039 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java @@ -0,0 +1,162 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.util.Maps; +import org.elasticsearch.env.Environment; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.FileWatcher; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.XPackPlugin; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.support.NoOpLogger; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.support.FileLineParser; +import org.elasticsearch.xpack.security.support.FileReloadListener; +import org.elasticsearch.xpack.security.support.SecurityFiles; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class FileServiceAccountsTokenStore extends CachingServiceAccountsTokenStore { + + private static final Logger logger = LogManager.getLogger(FileServiceAccountsTokenStore.class); + + private final Path file; + private final CopyOnWriteArrayList refreshListeners; + private volatile Map tokenHashes; + + public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool) { + super(env.settings(), threadPool); + file = resolveFile(env); + FileWatcher watcher = new FileWatcher(file.getParent()); + watcher.addListener(new FileReloadListener(file, this::tryReload)); + try { + resourceWatcherService.add(watcher, ResourceWatcherService.Frequency.HIGH); + } catch (IOException e) { + throw new ElasticsearchException("failed to start watching service_tokens file [{}]", e, file.toAbsolutePath()); + } + try { + tokenHashes = parseFile(file, logger); + } catch (IOException e) { + throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e); + } + refreshListeners = new CopyOnWriteArrayList<>(org.elasticsearch.common.collect.List.of(this::invalidateAll)); + } + + @Override + public void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + // This is done on the current thread instead of using a dedicated thread pool like API key does + // because it is not expected to have a large number of service tokens. + listener.onResponse(Optional.ofNullable(tokenHashes.get(token.getQualifiedName())) + .map(hash -> Hasher.verifyHash(token.getSecret(), hash)) + .orElse(false)); + } + + @Override + public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + final String principal = accountId.asPrincipal(); + final List tokenInfos = tokenHashes.keySet() + .stream() + .filter(k -> k.startsWith(principal + "/")) + .map(k -> TokenInfo.fileToken(Strings.substring(k, principal.length() + 1, k.length()))) + .collect(Collectors.toList()); + listener.onResponse(org.elasticsearch.common.collect.List.copyOf(tokenInfos)); + } + + public void addListener(Runnable listener) { + refreshListeners.add(listener); + } + + private void notifyRefresh() { + refreshListeners.forEach(Runnable::run); + } + + private void tryReload() { + final Map previousTokenHashes = tokenHashes; + tokenHashes = parseFileLenient(file, logger); + if (false == Maps.deepEquals(tokenHashes, previousTokenHashes)) { + logger.info("service tokens file [{}] changed. updating ...", file.toAbsolutePath()); + notifyRefresh(); + } + } + + // package private for testing + Map getTokenHashes() { + return tokenHashes; + } + + static Path resolveFile(Environment env) { + return XPackPlugin.resolveConfigFile(env, "service_tokens"); + } + + static Map parseFileLenient(Path path, @Nullable Logger logger) { + try { + return parseFile(path, logger); + } catch (Exception e) { + logger.error("failed to parse service tokens file [{}]. skipping/removing all tokens...", + path.toAbsolutePath()); + return org.elasticsearch.common.collect.Map.of(); + } + } + + static Map parseFile(Path path, @Nullable Logger logger) throws IOException { + final Logger thisLogger = logger == null ? NoOpLogger.INSTANCE : logger; + thisLogger.trace("reading service_tokens file [{}]...", path.toAbsolutePath()); + if (Files.exists(path) == false) { + thisLogger.trace("file [{}] does not exist", path.toAbsolutePath()); + return org.elasticsearch.common.collect.Map.of(); + } + final Map parsedTokenHashes = new HashMap<>(); + FileLineParser.parse(path, (lineNumber, line) -> { + line = line.trim(); + final int colon = line.indexOf(':'); + if (colon == -1) { + thisLogger.warn("invalid format at line #{} of service_tokens file [{}] - missing ':' character - ", lineNumber, path); + throw new IllegalStateException("Missing ':' character at line #" + lineNumber); + } + final String key = line.substring(0, colon); + // TODO: validate against known service accounts? + char[] hash = new char[line.length() - (colon + 1)]; + line.getChars(colon + 1, line.length(), hash, 0); + if (Hasher.resolveFromHash(hash) == Hasher.NOOP) { + thisLogger.warn("skipping plaintext service account token for key [{}]", key); + } else { + thisLogger.trace("parsed tokens for key [{}]", key); + final char[] previousHash = parsedTokenHashes.put(key, hash); + if (previousHash != null) { + thisLogger.warn("found duplicated key [{}], earlier entries are overridden", key); + } + } + }); + thisLogger.debug("parsed [{}] tokens from file [{}]", parsedTokenHashes.size(), path.toAbsolutePath()); + return org.elasticsearch.common.collect.Map.copyOf(parsedTokenHashes); + } + + static void writeFile(Path path, Map tokenHashes) { + SecurityFiles.writeFileAtomically( + path, tokenHashes, e -> String.format(Locale.ROOT, "%s:%s", e.getKey(), new String(e.getValue()))); + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java new file mode 100644 index 0000000000000..07abb3d3f22a2 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java @@ -0,0 +1,132 @@ +/* + * 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.security.authc.service; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; +import org.elasticsearch.cli.EnvironmentAwareCommand; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.LoggingAwareMultiCommand; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.support.FileAttributesChecker; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public class FileTokensTool extends LoggingAwareMultiCommand { + + public static void main(String[] args) throws Exception { + exit(new FileTokensTool().main(args, Terminal.DEFAULT)); + } + + public FileTokensTool() { + super("Manages elasticsearch service account file-tokens"); + subcommands.put("create", newCreateFileTokenCommand()); + subcommands.put("remove", newRemoveFileTokenCommand()); + subcommands.put("list", newListFileTokenCommand()); + } + + protected CreateFileTokenCommand newCreateFileTokenCommand() { + return new CreateFileTokenCommand(); + } + + protected RemoveFileTokenCommand newRemoveFileTokenCommand() { + return new RemoveFileTokenCommand(); + } + + protected ListFileTokenCommand newListFileTokenCommand() { + return new ListFileTokenCommand(); + } + + static class CreateFileTokenCommand extends EnvironmentAwareCommand { + + private final OptionSpec arguments; + + CreateFileTokenCommand() { + super("Create a file token for specified service account and token name"); + this.arguments = parser.nonOptions("service-account-principal token-name"); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + final Tuple tuple = parsePrincipalAndTokenName(arguments.values(options), env.settings()); + final String principal = tuple.v1(); + final String tokenName = tuple.v2(); + if (false == ServiceAccountService.isServiceAccountPrincipal(principal)) { + throw new UserException(ExitCodes.NO_USER, "Unknown service account principal: [" + principal + "]. Must be one of [" + + Strings.collectionToDelimitedString(ServiceAccountService.getServiceAccountPrincipals(), ",") + "]"); + } + if (false == ServiceAccountToken.isValidTokenName(tokenName)) { + throw new UserException(ExitCodes.CODE_ERROR, ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE); + } + final Hasher hasher = Hasher.resolve(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(env.settings())); + final Path serviceTokensFile = FileServiceAccountsTokenStore.resolveFile(env); + + FileAttributesChecker attributesChecker = new FileAttributesChecker(serviceTokensFile); + final Map tokenHashes = new TreeMap<>(FileServiceAccountsTokenStore.parseFile(serviceTokensFile, null)); + + try (ServiceAccountToken token = ServiceAccountToken.newToken(ServiceAccountId.fromPrincipal(principal), tokenName)) { + if (tokenHashes.containsKey(token.getQualifiedName())) { + throw new UserException(ExitCodes.CODE_ERROR, "Service token [" + token.getQualifiedName() + "] already exists"); + } + tokenHashes.put(token.getQualifiedName(), hasher.hash(token.getSecret())); + FileServiceAccountsTokenStore.writeFile(serviceTokensFile, tokenHashes); + terminal.println("SERVICE_TOKEN " + token.getQualifiedName() + " = " + token.asBearerString()); + } + + attributesChecker.check(terminal); + } + + static Tuple parsePrincipalAndTokenName(List arguments, Settings settings) throws UserException { + if (arguments.isEmpty()) { + throw new UserException(ExitCodes.USAGE, "Missing service-account-principal and token-name arguments"); + } else if (arguments.size() == 1) { + throw new UserException(ExitCodes.USAGE, "Missing token-name argument"); + } else if (arguments.size() > 2) { + throw new UserException( + ExitCodes.USAGE, + "Expected two arguments, service-account-principal and token-name, found extra: " + arguments.toString()); + } + return new Tuple<>(arguments.get(0), arguments.get(1)); + } + } + + static class RemoveFileTokenCommand extends EnvironmentAwareCommand { + + RemoveFileTokenCommand() { + super("Remove a file token for specified service account and token name"); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + throw new UnsupportedOperationException("remove command not implemented yet"); + } + } + + static class ListFileTokenCommand extends EnvironmentAwareCommand { + + ListFileTokenCommand() { + super("List file tokens for the specified service account"); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + throw new UnsupportedOperationException("list command not implemented yet"); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..fe1a4e06c1a1e --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStore.java @@ -0,0 +1,196 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.DocWriteRequest.OpType; +import org.elasticsearch.action.DocWriteResponse; +import org.elasticsearch.action.bulk.BulkAction; +import org.elasticsearch.action.bulk.BulkRequest; +import org.elasticsearch.action.bulk.TransportSingleItemBulkWriteAction; +import org.elasticsearch.action.get.GetAction; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexResponse; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.action.support.ContextPreservingActionListener; +import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.ScrollHelper; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; + +import java.io.IOException; +import java.time.Clock; +import java.util.Arrays; +import java.util.Collection; + +import static org.elasticsearch.action.bulk.TransportSingleItemBulkWriteAction.toSingleItemBulkRequest; +import static org.elasticsearch.index.mapper.MapperService.SINGLE_MAPPING_NAME; +import static org.elasticsearch.search.SearchService.DEFAULT_KEEPALIVE_SETTING; +import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; +import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; + +public class IndexServiceAccountsTokenStore extends CachingServiceAccountsTokenStore { + + private static final Logger logger = LogManager.getLogger(IndexServiceAccountsTokenStore.class); + static final String SERVICE_ACCOUNT_TOKEN_DOC_TYPE = "service_account_token"; + + private final Clock clock; + private final Client client; + private final SecurityIndexManager securityIndex; + private final ClusterService clusterService; + private final Hasher hasher; + + public IndexServiceAccountsTokenStore(Settings settings, ThreadPool threadPool, Clock clock, Client client, + SecurityIndexManager securityIndex, ClusterService clusterService, + CacheInvalidatorRegistry cacheInvalidatorRegistry) { + super(settings, threadPool); + this.clock = clock; + this.client = client; + this.securityIndex = securityIndex; + this.clusterService = clusterService; + cacheInvalidatorRegistry.registerCacheInvalidator("index_service_account_token", this); + this.hasher = Hasher.resolve(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(settings)); + } + + @Override + void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + final GetRequest getRequest = client + .prepareGet(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME, docIdForToken(token)) + .setFetchSource(true) + .request(); + securityIndex.checkIndexVersionThenExecute(listener::onFailure, () -> + executeAsyncWithOrigin(client, SECURITY_ORIGIN, GetAction.INSTANCE, getRequest, ActionListener.wrap(response -> { + if (response.isExists()) { + final String tokenHash = (String) response.getSource().get("password"); + assert tokenHash != null : "service account token hash cannot be null"; + listener.onResponse(Hasher.verifyHash(token.getSecret(), tokenHash.toCharArray())); + } else { + logger.trace("service account token [{}] not found in index", token.getQualifiedName()); + listener.onResponse(false); + }}, listener::onFailure))); + } + + public void createToken(Authentication authentication, CreateServiceAccountTokenRequest request, + ActionListener listener) { + final ServiceAccountId accountId = new ServiceAccountId(request.getNamespace(), request.getServiceName()); + if (false == ServiceAccountService.isServiceAccountPrincipal(accountId.asPrincipal())) { + listener.onFailure(new IllegalArgumentException("service account [" + accountId + "] does not exist")); + return; + } + final ServiceAccountToken token = ServiceAccountToken.newToken(accountId, request.getTokenName()); + try (XContentBuilder builder = newDocument(authentication, token)) { + final IndexRequest indexRequest = + client.prepareIndex(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME) + .setId(docIdForToken(token)) + .setSource(builder) + .setOpType(OpType.CREATE) + .setRefreshPolicy(request.getRefreshPolicy()) + .request(); + final BulkRequest bulkRequest = toSingleItemBulkRequest(indexRequest); + + securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () -> { + executeAsyncWithOrigin(client, SECURITY_ORIGIN, BulkAction.INSTANCE, bulkRequest, + TransportSingleItemBulkWriteAction.wrapBulkResponse(ActionListener.wrap(response -> { + assert DocWriteResponse.Result.CREATED == response.getResult() + : "an successful response of an OpType.CREATE request must have result of CREATED"; + listener.onResponse(CreateServiceAccountTokenResponse.created( + token.getTokenName(), token.asBearerString())); + }, listener::onFailure))); + }); + } catch (IOException e) { + listener.onFailure(e); + } + } + + @Override + public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + // TODO: wildcard support? + final BoolQueryBuilder query = QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("doc_type", SERVICE_ACCOUNT_TOKEN_DOC_TYPE)) + .must(QueryBuilders.termQuery("username", accountId.asPrincipal())); + final SearchRequest request = client.prepareSearch(SECURITY_MAIN_ALIAS) + .setScroll(DEFAULT_KEEPALIVE_SETTING.get(getSettings())) + .setQuery(query) + .setSize(1000) + .setFetchSource(false) + .request(); + request.indicesOptions().ignoreUnavailable(); + + logger.trace("Searching tokens for service account [{}]", accountId); + ScrollHelper.fetchAllByEntity(client, request, + new ContextPreservingActionListener<>(client.threadPool().getThreadContext().newRestorableContext(false), listener), + hit -> extractTokenInfo(hit.getId(), accountId)); + } + + private String docIdForToken(ServiceAccountToken token) { + return SERVICE_ACCOUNT_TOKEN_DOC_TYPE + "-" + token.getQualifiedName(); + } + + private XContentBuilder newDocument(Authentication authentication, ServiceAccountToken serviceAccountToken) throws IOException { + final Version version = clusterService.state().nodes().getMinNodeVersion(); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.startObject() + .field("doc_type", SERVICE_ACCOUNT_TOKEN_DOC_TYPE) + .field("version", version.id) + .field("username", serviceAccountToken.getAccountId().asPrincipal()) + .field("name", serviceAccountToken.getTokenName()) + .field("creation_time", clock.instant().toEpochMilli()) + .field("enabled", true) + .startObject("creator") + .field("principal", authentication.getUser().principal()) + .field("full_name", authentication.getUser().fullName()) + .field("email", authentication.getUser().email()) + .field("metadata", authentication.getUser().metadata()) + .field("realm", authentication.getSourceRealm().getName()) + .field("realm_type", authentication.getSourceRealm().getType()) + .endObject(); + + byte[] utf8Bytes = null; + final char[] tokenHash = hasher.hash(serviceAccountToken.getSecret()); + try { + utf8Bytes = CharArrays.toUtf8Bytes(tokenHash); + builder.field("password").utf8Value(utf8Bytes, 0, utf8Bytes.length); + } finally { + if (utf8Bytes != null) { + Arrays.fill(utf8Bytes, (byte) 0); + } + Arrays.fill(tokenHash, (char) 0); + } + builder.endObject(); + return builder; + } + + private TokenInfo extractTokenInfo(String docId, ServiceAccountId accountId) { + // Prefix is SERVICE_ACCOUNT_TOKEN_DOC_TYPE + "-" + accountId.asPrincipal() + "/" + final int prefixLength = SERVICE_ACCOUNT_TOKEN_DOC_TYPE.length() + accountId.asPrincipal().length() + 2; + return TokenInfo.indexToken(Strings.substring(docId, prefixLength, docId.length())); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccount.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccount.java new file mode 100644 index 0000000000000..ba97ac2b8196c --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccount.java @@ -0,0 +1,92 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.util.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.user.User; + +import java.io.IOException; +import java.util.Objects; + +public interface ServiceAccount { + + ServiceAccountId id(); + + RoleDescriptor roleDescriptor(); + + User asUser(); + + final class ServiceAccountId { + + private final String namespace; + private final String serviceName; + + public static ServiceAccountId fromPrincipal(String principal) { + final int split = principal.indexOf('/'); + if (split == -1) { + throw new IllegalArgumentException( + "a service account ID must be in the form {namespace}/{service-name}, but was [" + principal + "]"); + } + return new ServiceAccountId(principal.substring(0, split), principal.substring(split + 1)); + } + + public ServiceAccountId(String namespace, String serviceName) { + this.namespace = namespace; + this.serviceName = serviceName; + if (Strings.isBlank(this.namespace)) { + throw new IllegalArgumentException("the namespace of a service account ID must not be empty"); + } + if (Strings.isBlank(this.serviceName)) { + throw new IllegalArgumentException("the service-name of a service account ID must not be empty"); + } + } + + public ServiceAccountId(StreamInput in) throws IOException { + this.namespace = in.readString(); + this.serviceName = in.readString(); + } + + public void write(StreamOutput out) throws IOException { + out.writeString(namespace); + out.writeString(serviceName); + } + + public String namespace() { + return namespace; + } + + public String serviceName() { + return serviceName; + } + + public String asPrincipal() { + return namespace + "/" + serviceName; + } + + @Override + public String toString() { + return asPrincipal(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ServiceAccountId that = (ServiceAccountId) o; + return namespace.equals(that.namespace) && serviceName.equals(that.serviceName); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, serviceName); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java new file mode 100644 index 0000000000000..10c1e74b849e7 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java @@ -0,0 +1,143 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; + +import java.util.Collection; + +import static org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ACCOUNTS; + +public class ServiceAccountService { + + public static final String REALM_TYPE = "service_account"; + public static final String REALM_NAME = "service_account"; + + private static final Logger logger = LogManager.getLogger(ServiceAccountService.class); + + private final ServiceAccountsTokenStore serviceAccountsTokenStore; + private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; + + public ServiceAccountService(ServiceAccountsTokenStore serviceAccountsTokenStore, HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + this.serviceAccountsTokenStore = serviceAccountsTokenStore; + this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; + } + + public static boolean isServiceAccount(Authentication authentication) { + return REALM_TYPE.equals(authentication.getAuthenticatedBy().getType()) && null == authentication.getLookedUpBy(); + } + + public static boolean isServiceAccountPrincipal(String principal) { + return ACCOUNTS.containsKey(principal); + } + + public static Collection getServiceAccountPrincipals() { + return ACCOUNTS.keySet(); + } + + /** + * Parses a token object from the content of a {@link ServiceAccountToken#asBearerString()} bearer string}. + * This bearer string would typically be + * {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header. + * + *

+ * This method does not validate the credential, it simply parses it. + * There is no guarantee that the {@link ServiceAccountToken#getSecret() secret} is valid, + * or even that the {@link ServiceAccountToken#getAccountId() account} exists. + *

+ * @param bearerString A raw token string (if this is from an HTTP header, then the "Bearer " prefix must be removed before + * calling this method. + * @return An unvalidated token object. + */ + public static ServiceAccountToken tryParseToken(SecureString bearerString) { + try { + if (bearerString == null) { + return null; + } + return ServiceAccountToken.fromBearerString(bearerString); + } catch (Exception e) { + logger.trace("Cannot parse possible service account token", e); + return null; + } + } + + public void findTokensFor(ServiceAccountId accountId, String nodeName, ActionListener listener) { + serviceAccountsTokenStore.findTokensFor(accountId, ActionListener.wrap(tokenInfos -> { + listener.onResponse(new GetServiceAccountTokensResponse(accountId.asPrincipal(), nodeName, tokenInfos)); + }, listener::onFailure)); + } + + public void authenticateToken(ServiceAccountToken serviceAccountToken, String nodeName, ActionListener listener) { + logger.trace("attempt to authenticate service account token [{}]", serviceAccountToken.getQualifiedName()); + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "service account authentication", () -> { + if (ElasticServiceAccounts.NAMESPACE.equals(serviceAccountToken.getAccountId().namespace()) == false) { + logger.debug("only [{}] service accounts are supported, but received [{}]", + ElasticServiceAccounts.NAMESPACE, serviceAccountToken.getAccountId().asPrincipal()); + listener.onFailure(createAuthenticationException(serviceAccountToken)); + return; + } + + final ServiceAccount account = ACCOUNTS.get(serviceAccountToken.getAccountId().asPrincipal()); + if (account == null) { + logger.debug("the [{}] service account does not exist", serviceAccountToken.getAccountId().asPrincipal()); + listener.onFailure(createAuthenticationException(serviceAccountToken)); + return; + } + + serviceAccountsTokenStore.authenticate(serviceAccountToken, ActionListener.wrap(success -> { + if (success) { + listener.onResponse(createAuthentication(account, serviceAccountToken, nodeName)); + } else { + final ElasticsearchSecurityException e = createAuthenticationException(serviceAccountToken); + logger.debug(e.getMessage()); + listener.onFailure(e); + } + }, listener::onFailure)); + }); + } + + public void getRoleDescriptor(Authentication authentication, ActionListener listener) { + assert isServiceAccount(authentication) : "authentication is not for service account: " + authentication; + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "service account role descriptor resolving", () -> { + final String principal = authentication.getUser().principal(); + final ServiceAccount account = ACCOUNTS.get(principal); + if (account == null) { + listener.onFailure(new ElasticsearchSecurityException( + "cannot load role for service account [" + principal + "] - no such service account")); + return; + } + listener.onResponse(account.roleDescriptor()); + }); + } + + private Authentication createAuthentication(ServiceAccount account, ServiceAccountToken token, String nodeName) { + final User user = account.asUser(); + final Authentication.RealmRef authenticatedBy = new Authentication.RealmRef(REALM_NAME, REALM_TYPE, nodeName); + return new Authentication(user, authenticatedBy, null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", token.getTokenName())); + } + + private ElasticsearchSecurityException createAuthenticationException(ServiceAccountToken serviceAccountToken) { + return new ElasticsearchSecurityException("failed to authenticate service account [{}] with token name [{}]", + RestStatus.UNAUTHORIZED, + serviceAccountToken.getAccountId().asPrincipal(), + serviceAccountToken.getTokenName()); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java new file mode 100644 index 0000000000000..9194cda5aa921 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountToken.java @@ -0,0 +1,173 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A decoded credential that may be used to authenticate a {@link ServiceAccount}. + * It consists of: + *
    + *
  1. A {@link #getAccountId() service account id}
  2. + *
  3. The {@link #getTokenName() name of the token} to be used
  4. + *
  5. The {@link #getSecret() secret credential} for that token
  6. + *
+ */ +public class ServiceAccountToken implements AuthenticationToken, Closeable { + + public static final String INVALID_TOKEN_NAME_MESSAGE = "service account token name must have at least 1 character " + + "and at most 256 characters that are alphanumeric (A-Z, a-z, 0-9) or hyphen (-) or underscore (_). " + + "It must not begin with an underscore (_)."; + + private static final Pattern VALID_TOKEN_NAME = Pattern.compile("^[a-zA-Z0-9-][a-zA-Z0-9_-]{0,255}$"); + + public static final byte MAGIC_BYTE = '\0'; + public static final byte TOKEN_TYPE = '\1'; + public static final byte RESERVED_BYTE = '\0'; + public static final byte FORMAT_VERSION = '\1'; + public static final byte[] PREFIX = new byte[] { MAGIC_BYTE, TOKEN_TYPE, RESERVED_BYTE, FORMAT_VERSION }; + + private static final Logger logger = LogManager.getLogger(ServiceAccountToken.class); + + private final ServiceAccountId accountId; + private final String tokenName; + private final SecureString secret; + + // pkg private for testing + ServiceAccountToken(ServiceAccountId accountId, String tokenName, SecureString secret) { + this.accountId = Objects.requireNonNull(accountId, "service account ID cannot be null"); + if (false == isValidTokenName(tokenName)) { + throw new IllegalArgumentException(INVALID_TOKEN_NAME_MESSAGE); + } + this.tokenName = tokenName; + this.secret = Objects.requireNonNull(secret, "service account token secret cannot be null"); + } + + public ServiceAccountId getAccountId() { + return accountId; + } + + public String getTokenName() { + return tokenName; + } + + public SecureString getSecret() { + return secret; + } + + public String getQualifiedName() { + return getAccountId().asPrincipal() + "/" + tokenName; + } + + public SecureString asBearerString() throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + out.write(PREFIX, 0, PREFIX.length); + out.write(getQualifiedName().getBytes(StandardCharsets.UTF_8)); + out.write(':'); + out.write(secret.toString().getBytes(StandardCharsets.UTF_8)); + final String base64 = Base64.getEncoder().withoutPadding().encodeToString(out.toByteArray()); + return new SecureString(base64.toCharArray()); + } + } + + public static ServiceAccountToken fromBearerString(SecureString bearerString) throws IOException { + final byte[] bytes = CharArrays.toUtf8Bytes(bearerString.getChars()); + logger.trace("parsing token bytes {}", MessageDigests.toHexString(bytes)); + final byte[] decodedBytes = Base64.getDecoder().decode(bytes); + final byte[] prefixBytes = Arrays.copyOfRange(decodedBytes, 0, 4); + if (decodedBytes.length < 4 || false == Arrays.equals(prefixBytes, PREFIX)) { + logger.trace(() -> new ParameterizedMessage( + "service account token expects the 4 leading bytes to be {}, got {}.", + Arrays.toString(PREFIX), Arrays.toString(prefixBytes))); + return null; + } + final byte[] contentBytes = Arrays.copyOfRange(decodedBytes, 4, decodedBytes.length); + final char[] content = CharArrays.utf8BytesToChars(contentBytes); + final int i = UsernamePasswordToken.indexOfColon(content); + if (i < 0) { + logger.trace("failed to extract qualified service token name and secret, missing ':'"); + return null; + } + final String qualifiedName = new String(Arrays.copyOfRange(content, 0, i)); + final String[] split = Strings.delimitedListToStringArray(qualifiedName, "/"); + if (split == null || split.length != 3) { + logger.trace("The qualified name of a service token should take format of " + + "'namespace/service_name/token_name', got [{}]", qualifiedName); + return null; + } + return new ServiceAccountToken(new ServiceAccountId(split[0], split[1]), split[2], + new SecureString(Arrays.copyOfRange(content, i + 1, content.length))); + } + + @Override + public void close() { + secret.close(); + } + + @Override + public String toString() { + return getQualifiedName(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + ServiceAccountToken that = (ServiceAccountToken) o; + return accountId.equals(that.accountId) && tokenName.equals(that.tokenName) && secret.equals(that.secret); + } + + @Override + public int hashCode() { + return Objects.hash(accountId, tokenName, secret); + } + + public static ServiceAccountToken newToken(ServiceAccountId accountId, String tokenName) { + return new ServiceAccountToken(accountId, tokenName, UUIDs.randomBase64UUIDSecureString()); + } + + @Override + public String principal() { + return accountId.asPrincipal(); + } + + @Override + public Object credentials() { + return secret; + } + + @Override + public void clearCredentials() { + close(); + } + + public static boolean isValidTokenName(String name) { + return name != null && VALID_TOKEN_NAME.matcher(name).matches(); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..37590e545ed85 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java @@ -0,0 +1,31 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.util.Collection; + +/** + * The interface should be implemented by credential stores of different backends. + */ +public interface ServiceAccountsTokenStore { + + /** + * Verify the given token for encapsulated service account and credential + */ + void authenticate(ServiceAccountToken token, ActionListener listener); + + /** + * Get all tokens belong to the given service account id + */ + void findTokensFor(ServiceAccountId accountId, ActionListener> listener); + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/HttpTlsRuntimeCheck.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/HttpTlsRuntimeCheck.java new file mode 100644 index 0000000000000..7b297229aa61f --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/HttpTlsRuntimeCheck.java @@ -0,0 +1,67 @@ +/* + * 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.security.authc.support; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.discovery.DiscoveryModule; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.xpack.core.XPackSettings; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public class HttpTlsRuntimeCheck { + + private static final Logger logger = LogManager.getLogger(HttpTlsRuntimeCheck.class); + + private final AtomicBoolean initialized = new AtomicBoolean(false); + private final Boolean httpTlsEnabled; + private final SetOnce transportReference; + private final Boolean securityEnabled; + private final boolean singleNodeDiscovery; + private boolean enforce; + + public HttpTlsRuntimeCheck(Settings settings, SetOnce transportReference) { + this.transportReference = transportReference; + this.securityEnabled = XPackSettings.SECURITY_ENABLED.get(settings); + this.httpTlsEnabled = XPackSettings.HTTP_SSL_ENABLED.get(settings); + this.singleNodeDiscovery = "single-node".equals(DiscoveryModule.DISCOVERY_TYPE_SETTING.get(settings)); + } + + public void checkTlsThenExecute(Consumer exceptionConsumer, String featureName, Runnable andThen) { + // If security is enabled, but TLS is not enabled for the HTTP interface + if (securityEnabled && false == httpTlsEnabled) { + if (false == initialized.get()) { + final Transport transport = transportReference.get(); + if (transport == null) { + exceptionConsumer.accept(new ElasticsearchException("transport cannot be null")); + return; + } + final boolean boundToLocal = Arrays.stream(transport.boundAddress().boundAddresses()) + .allMatch(b -> b.address().getAddress().isLoopbackAddress()) + && transport.boundAddress().publishAddress().address().getAddress().isLoopbackAddress(); + this.enforce = false == boundToLocal && false == singleNodeDiscovery; + initialized.set(true); + } + if (enforce) { + final ParameterizedMessage message = new ParameterizedMessage( + "[{}] requires TLS for the HTTP interface", featureName); + logger.debug(message); + exceptionConsumer.accept(new ElasticsearchException(message.getFormattedMessage())); + return; + } + } + andThen.run(); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index 4a6dc6bc6abf6..675c663e3a2fb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -54,6 +54,7 @@ import org.elasticsearch.xpack.core.security.user.XPackSecurityUser; import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import java.util.ArrayList; @@ -107,6 +108,7 @@ public class CompositeRolesStore { private final AtomicLong numInvalidation = new AtomicLong(); private final AnonymousUser anonymousUser; private final ApiKeyService apiKeyService; + private final ServiceAccountService serviceAccountService; private final boolean isAnonymousEnabled; private final List, ActionListener>> builtInRoleProviders; private final List, ActionListener>> allRoleProviders; @@ -115,7 +117,8 @@ public CompositeRolesStore(Settings settings, FileRolesStore fileRolesStore, Nat ReservedRolesStore reservedRolesStore, NativePrivilegeStore privilegeStore, List, ActionListener>> rolesProviders, ThreadContext threadContext, XPackLicenseState licenseState, FieldPermissionsCache fieldPermissionsCache, - ApiKeyService apiKeyService, DocumentSubsetBitsetCache dlsBitsetCache, + ApiKeyService apiKeyService, ServiceAccountService serviceAccountService, + DocumentSubsetBitsetCache dlsBitsetCache, Consumer> effectiveRoleDescriptorsConsumer) { this.fileRolesStore = Objects.requireNonNull(fileRolesStore); this.dlsBitsetCache = Objects.requireNonNull(dlsBitsetCache); @@ -125,6 +128,7 @@ public CompositeRolesStore(Settings settings, FileRolesStore fileRolesStore, Nat this.licenseState = Objects.requireNonNull(licenseState); this.fieldPermissionsCache = Objects.requireNonNull(fieldPermissionsCache); this.apiKeyService = Objects.requireNonNull(apiKeyService); + this.serviceAccountService = Objects.requireNonNull(serviceAccountService); this.effectiveRoleDescriptorsConsumer = Objects.requireNonNull(effectiveRoleDescriptorsConsumer); CacheBuilder builder = CacheBuilder.builder(); final int cacheSize = CACHE_SIZE_SETTING.get(settings); @@ -225,42 +229,10 @@ public void getRoles(User user, Authentication authentication, ActionListener { - if (role == Role.EMPTY) { - buildAndCacheRoleForApiKey(authentication, true, roleActionListener); - } else { - buildAndCacheRoleForApiKey(authentication, true, ActionListener.wrap( - limitedByRole -> roleActionListener.onResponse( - limitedByRole == Role.EMPTY ? role : LimitedRole.createLimitedRole(role, limitedByRole)), - roleActionListener::onFailure - )); - } - }, - roleActionListener::onFailure - )); - } else { - apiKeyService.getRoleForApiKey(authentication, ActionListener.wrap(apiKeyRoleDescriptors -> { - final List descriptors = apiKeyRoleDescriptors.getRoleDescriptors(); - if (descriptors == null) { - roleActionListener.onFailure(new IllegalStateException("missing role descriptors")); - } else if (apiKeyRoleDescriptors.getLimitedByRoleDescriptors() == null) { - buildAndCacheRoleFromDescriptors(descriptors, - apiKeyRoleDescriptors.getApiKeyId() + "_role_desc", roleActionListener); - } else { - buildAndCacheRoleFromDescriptors(descriptors, apiKeyRoleDescriptors.getApiKeyId() + "_role_desc", - ActionListener.wrap( - role -> buildAndCacheRoleFromDescriptors(apiKeyRoleDescriptors.getLimitedByRoleDescriptors(), - apiKeyRoleDescriptors.getApiKeyId() + "_limited_role_desc", ActionListener.wrap( - limitedBy -> roleActionListener.onResponse(LimitedRole.createLimitedRole(role, limitedBy)), - roleActionListener::onFailure)), roleActionListener::onFailure)); - } - }, roleActionListener::onFailure)); - } - + if (ServiceAccountService.isServiceAccount(authentication)) { + getRolesForServiceAccount(authentication, roleActionListener); + } else if (ApiKeyService.isApiKeyAuthentication(authentication)) { + getRolesForApiKey(authentication, roleActionListener); } else { Set roleNames = new HashSet<>(Arrays.asList(user.roles())); if (isAnonymousEnabled && anonymousUser.equals(user) == false) { @@ -280,6 +252,56 @@ public void getRoles(User user, Authentication authentication, ActionListener roleActionListener) { + serviceAccountService.getRoleDescriptor(authentication, ActionListener.wrap(roleDescriptor -> { + final RoleKey roleKey = new RoleKey(org.elasticsearch.common.collect.Set.of(roleDescriptor.getName()), "service_account"); + final Role existing = roleCache.get(roleKey); + if (existing == null) { + final long invalidationCounter = numInvalidation.get(); + buildThenMaybeCacheRole(roleKey, org.elasticsearch.common.collect.List.of(roleDescriptor), + org.elasticsearch.common.collect.Set.of(), true, invalidationCounter, roleActionListener); + } else { + roleActionListener.onResponse(existing); + } + }, roleActionListener::onFailure)); + } + + private void getRolesForApiKey(Authentication authentication, ActionListener roleActionListener) { + if (authentication.getVersion().onOrAfter(VERSION_API_KEY_ROLES_AS_BYTES)) { + buildAndCacheRoleForApiKey(authentication, false, ActionListener.wrap( + role -> { + if (role == Role.EMPTY) { + buildAndCacheRoleForApiKey(authentication, true, roleActionListener); + } else { + buildAndCacheRoleForApiKey(authentication, true, ActionListener.wrap( + limitedByRole -> roleActionListener.onResponse( + limitedByRole == Role.EMPTY ? role : LimitedRole.createLimitedRole(role, limitedByRole)), + roleActionListener::onFailure + )); + } + }, + roleActionListener::onFailure + )); + } else { + apiKeyService.getRoleForApiKey(authentication, ActionListener.wrap(apiKeyRoleDescriptors -> { + final List descriptors = apiKeyRoleDescriptors.getRoleDescriptors(); + if (descriptors == null) { + roleActionListener.onFailure(new IllegalStateException("missing role descriptors")); + } else if (apiKeyRoleDescriptors.getLimitedByRoleDescriptors() == null) { + buildAndCacheRoleFromDescriptors(descriptors, + apiKeyRoleDescriptors.getApiKeyId() + "_role_desc", roleActionListener); + } else { + buildAndCacheRoleFromDescriptors(descriptors, apiKeyRoleDescriptors.getApiKeyId() + "_role_desc", + ActionListener.wrap( + role -> buildAndCacheRoleFromDescriptors(apiKeyRoleDescriptors.getLimitedByRoleDescriptors(), + apiKeyRoleDescriptors.getApiKeyId() + "_limited_role_desc", ActionListener.wrap( + limitedBy -> roleActionListener.onResponse(LimitedRole.createLimitedRole(role, limitedBy)), + roleActionListener::onFailure)), roleActionListener::onFailure)); + } + }, roleActionListener::onFailure)); + } + } + public void buildAndCacheRoleFromDescriptors(Collection roleDescriptors, String source, ActionListener listener) { if (ROLES_STORE_SOURCE.equals(source)) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestCreateServiceAccountTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestCreateServiceAccountTokenAction.java new file mode 100644 index 0000000000000..b26835fb8afa7 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestCreateServiceAccountTokenAction.java @@ -0,0 +1,64 @@ +/* + * 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.security.rest.action.service; + +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; +import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; +import static org.elasticsearch.rest.RestRequest.Method.PUT; + +public class RestCreateServiceAccountTokenAction extends SecurityBaseRestHandler { + + public RestCreateServiceAccountTokenAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public List routes() { + return org.elasticsearch.common.collect.List.of( + new Route(POST, "/_security/service/{namespace}/{service}/credential/token/{name}"), + new Route(PUT, "/_security/service/{namespace}/{service}/credential/token/{name}"), + new Route(POST, "/_security/service/{namespace}/{service}/credential/token")); + } + + @Override + public String getName() { + return "xpack_security_create_service_account_token"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { + String tokenName = request.param("name"); + if (Strings.isNullOrEmpty(tokenName)) { + tokenName = UUIDs.base64UUID(); + } + final CreateServiceAccountTokenRequest createServiceAccountTokenRequest = new CreateServiceAccountTokenRequest( + request.param("namespace"), request.param("service"), tokenName); + final String refreshPolicy = request.param("refresh"); + if (refreshPolicy != null) { + createServiceAccountTokenRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.parse(refreshPolicy)); + } + + return channel -> client.execute(CreateServiceAccountTokenAction.INSTANCE, + createServiceAccountTokenRequest, + new RestToXContentListener<>(channel)); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestGetServiceAccountTokensAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestGetServiceAccountTokensAction.java new file mode 100644 index 0000000000000..d2c5dc63e570c --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/service/RestGetServiceAccountTokensAction.java @@ -0,0 +1,50 @@ +/* + * 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.security.rest.action.service; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensRequest; +import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.GET; + +public class RestGetServiceAccountTokensAction extends SecurityBaseRestHandler { + + public RestGetServiceAccountTokensAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public List routes() { + return org.elasticsearch.common.collect.List.of( + new Route(GET, "/_security/service/{namespace}/{service}/credential") + ); + } + + @Override + public String getName() { + return "xpack_security_get_service_account_tokens"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { + final GetServiceAccountTokensRequest getServiceAccountTokensRequest = + new GetServiceAccountTokensRequest(request.param("namespace"), request.param("service")); + return channel -> client.execute(GetServiceAccountTokensAction.INSTANCE, + getServiceAccountTokensRequest, + new RestToXContentListener<>(channel)); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java new file mode 100644 index 0000000000000..8fd03a6d4cb82 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileLineParser.java @@ -0,0 +1,33 @@ +/* + * 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.security.support; + +import org.apache.logging.log4j.util.Strings; +import org.elasticsearch.common.CheckedBiConsumer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class FileLineParser { + public static void parse(Path path, CheckedBiConsumer lineParser) throws IOException { + final List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + + int lineNumber = 0; + for (String line : lines) { + lineNumber++; + if (line.startsWith("#") || Strings.isBlank(line)) { // comment or blank + continue; + } + + lineParser.accept(lineNumber, line); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java new file mode 100644 index 0000000000000..560178e0c1349 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FileReloadListener.java @@ -0,0 +1,40 @@ +/* + * 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.security.support; + +import org.elasticsearch.watcher.FileChangesListener; + +import java.nio.file.Path; + +public class FileReloadListener implements FileChangesListener { + + private final Path path; + private final Runnable reload; + + public FileReloadListener(Path path, Runnable reload) { + this.path = path; + this.reload = reload; + } + + @Override + public void onFileCreated(Path file) { + onFileChanged(file); + } + + @Override + public void onFileDeleted(Path file) { + onFileChanged(file); + } + + @Override + public void onFileChanged(Path file) { + if (file.equals(this.path)) { + reload.run(); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java index 4e79a80f313ce..5ee40b3f02fbe 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java @@ -135,6 +135,7 @@ public Settings nodeSettings(int nodeOrdinal, Settings otherSettings) { writeFile(xpackConf, "users", configUsers()); writeFile(xpackConf, "users_roles", configUsersRoles()); writeFile(xpackConf, "operator_users.yml", configOperatorUsers()); + writeFile(xpackConf, "service_tokens", configServiceTokens()); Settings.Builder builder = Settings.builder() .put(Environment.PATH_HOME_SETTING.getKey(), home) @@ -214,6 +215,10 @@ protected String configOperatorUsers() { return ""; } + protected String configServiceTokens() { + return ""; + } + protected String nodeClientUsername() { return TEST_USER_NAME; } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenActionTests.java new file mode 100644 index 0000000000000..4bc97ad1a1106 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportCreateServiceAccountTokenActionTests.java @@ -0,0 +1,108 @@ +/* + * 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.security.action.service; + +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.BoundTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountsTokenStore; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.junit.Before; +import org.mockito.Mockito; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.Collections; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TransportCreateServiceAccountTokenActionTests extends ESTestCase { + + private IndexServiceAccountsTokenStore indexServiceAccountsTokenStore; + private SecurityContext securityContext; + private TransportCreateServiceAccountTokenAction transportCreateServiceAccountTokenAction; + private Transport transport; + + @Before + @SuppressForbidden(reason = "Allow accessing localhost") + public void init() throws IOException { + indexServiceAccountsTokenStore = mock(IndexServiceAccountsTokenStore.class); + securityContext = mock(SecurityContext.class); + final Settings.Builder builder = Settings.builder() + .put("xpack.security.enabled", true); + transport = mock(Transport.class); + final TransportAddress transportAddress; + if (randomBoolean()) { + transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + } else { + transportAddress = new TransportAddress(InetAddress.getLocalHost(), 9300); + } + if (randomBoolean()) { + builder.put("xpack.security.http.ssl.enabled", true); + } else { + builder.put("discovery.type", "single-node"); + } + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + transportCreateServiceAccountTokenAction = new TransportCreateServiceAccountTokenAction( + mock(TransportService.class), new ActionFilters(Collections.emptySet()), + indexServiceAccountsTokenStore, securityContext, new HttpTlsRuntimeCheck(builder.build(), new SetOnce<>(transport))); + } + + public void testAuthenticationIsRequired() { + when(securityContext.getAuthentication()).thenReturn(null); + final PlainActionFuture future = new PlainActionFuture<>(); + transportCreateServiceAccountTokenAction.doExecute(mock(Task.class), mock(CreateServiceAccountTokenRequest.class), future); + final IllegalStateException e = expectThrows(IllegalStateException.class, future::actionGet); + assertThat(e.getMessage(), containsString("authentication is required")); + } + + public void testExecutionWillDelegate() { + final Authentication authentication = mock(Authentication.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + final CreateServiceAccountTokenRequest request = mock(CreateServiceAccountTokenRequest.class); + final PlainActionFuture future = new PlainActionFuture<>(); + transportCreateServiceAccountTokenAction.doExecute(mock(Task.class), request, future); + verify(indexServiceAccountsTokenStore).createToken(authentication, request, future); + } + + public void testTlsRequired() { + Mockito.reset(transport); + final Settings settings = Settings.builder() + .put("xpack.security.http.ssl.enabled", false) + .build(); + final TransportAddress transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + + TransportCreateServiceAccountTokenAction action = new TransportCreateServiceAccountTokenAction( + mock(TransportService.class), new ActionFilters(Collections.emptySet()), + indexServiceAccountsTokenStore, securityContext, new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), mock(CreateServiceAccountTokenRequest.class), future); + final ElasticsearchException e = expectThrows(ElasticsearchException.class, future::actionGet); + assertThat(e.getMessage(), containsString("[create service account token] requires TLS for the HTTP interface")); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensActionTests.java new file mode 100644 index 0000000000000..687c41420c30c --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountTokensActionTests.java @@ -0,0 +1,103 @@ +/* + * 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.security.action.service; + +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.BoundTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountTokensResponse; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.junit.Before; +import org.mockito.Mockito; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TransportGetServiceAccountTokensActionTests extends ESTestCase { + + private TransportGetServiceAccountTokensAction transportGetServiceAccountTokensAction; + private ServiceAccountService serviceAccountService; + private Transport transport; + + @Before + @SuppressForbidden(reason = "Allow accessing localhost") + public void init() throws UnknownHostException { + final Settings.Builder builder = Settings.builder() + .put("node.name", "node_name") + .put("xpack.security.enabled", true); + transport = mock(Transport.class); + final TransportAddress transportAddress; + if (randomBoolean()) { + transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + } else { + transportAddress = new TransportAddress(InetAddress.getLocalHost(), 9300); + } + if (randomBoolean()) { + builder.put("xpack.security.http.ssl.enabled", true); + } else { + builder.put("discovery.type", "single-node"); + } + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + final Settings settings = builder.build(); + serviceAccountService = mock(ServiceAccountService.class); + transportGetServiceAccountTokensAction = new TransportGetServiceAccountTokensAction( + mock(TransportService.class), new ActionFilters(Collections.emptySet()), + settings, serviceAccountService, new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); + } + + public void testDoExecuteWillDelegate() { + final GetServiceAccountTokensRequest request = + new GetServiceAccountTokensRequest(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + @SuppressWarnings("rawtypes") + final ActionListener listener = mock(ActionListener.class); + //noinspection unchecked + transportGetServiceAccountTokensAction.doExecute(mock(Task.class), request, listener); + verify(serviceAccountService).findTokensFor( + eq(new ServiceAccount.ServiceAccountId(request.getNamespace(), request.getServiceName())), + eq("node_name"), eq(listener)); + } + + public void testTlsRequired() { + Mockito.reset(transport); + final Settings settings = Settings.builder() + .put("xpack.security.http.ssl.enabled", false) + .build(); + final TransportAddress transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + final TransportGetServiceAccountTokensAction action = new TransportGetServiceAccountTokensAction( + mock(TransportService.class), new ActionFilters(Collections.emptySet()), + settings, mock(ServiceAccountService.class), new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), mock(GetServiceAccountTokensRequest.class), future); + final ElasticsearchException e = expectThrows(ElasticsearchException.class, future::actionGet); + assertThat(e.getMessage(), containsString("[get service account tokens] requires TLS for the HTTP interface")); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java index 2f7b94fe3af49..d7d062744bc3c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java @@ -15,8 +15,7 @@ import org.elasticsearch.cluster.ClusterState; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.UUIDs; -import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; -import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.Index; import org.elasticsearch.indices.IndexClosedException; @@ -38,9 +37,6 @@ import org.junit.After; import org.junit.Before; -import java.io.ByteArrayOutputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import java.time.Clock; import java.util.Base64; import java.util.Collections; @@ -132,13 +128,11 @@ public void testInvalidateTokensWhenIndexClosed() throws Exception { } private String generateAccessTokenString() throws Exception { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(TokenService.MINIMUM_BASE64_BYTES); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput(TokenService.MINIMUM_BASE64_BYTES)) { out.setVersion(Version.CURRENT); Version.writeVersion(Version.CURRENT, out); out.writeString(UUIDs.randomBase64UUID()); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 452bde5e1cdb2..fb5c1d09d0d7f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -88,6 +88,7 @@ import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.AuthenticationService.Authenticator; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -109,6 +110,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -168,6 +170,7 @@ public class AuthenticationServiceTests extends ESTestCase { private ThreadContext threadContext; private TokenService tokenService; private ApiKeyService apiKeyService; + private ServiceAccountService serviceAccountService; private SecurityIndexManager securityIndex; private Client client; private InetSocketAddress remoteAddress; @@ -261,11 +264,18 @@ public void init() throws Exception { mock(CacheInvalidatorRegistry.class), threadPool); tokenService = new TokenService(settings, Clock.systemUTC(), client, licenseState, securityContext, securityIndex, securityIndex, clusterService); + serviceAccountService = mock(ServiceAccountService.class); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(null); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); operatorPrivilegesService = mock(OperatorPrivileges.OperatorPrivilegesService.class); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, new AnonymousUser(settings), tokenService, apiKeyService, + threadPool, new AnonymousUser(settings), tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); } @@ -505,7 +515,7 @@ public void testAuthenticateSmartRealmOrderingDisabled() { .build(); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); User user = new User("_username", "r1"); when(firstRealm.supports(token)).thenReturn(true); mockAuthenticate(firstRealm, token, null); @@ -874,7 +884,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { ThreadContext threadContext1 = threadPool1.getThreadContext(); service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool1, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); boolean requestIdAlreadyPresent = randomBoolean(); SetOnce reqId = new SetOnce<>(); if (requestIdAlreadyPresent) { @@ -915,7 +925,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { try (ThreadContext.StoredContext ignore = threadContext2.stashContext()) { service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); threadContext2.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); BytesStreamOutput output = new BytesStreamOutput(); @@ -929,7 +939,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { threadPool2.getThreadContext().putHeader(AuthenticationField.AUTHENTICATION_KEY, header); service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); service.authenticate("_action", new InternalRequest(), SystemUser.INSTANCE, ActionListener.wrap(result -> { if (requestIdAlreadyPresent) { assertThat(expectAuditRequestId(threadPool2.getThreadContext()), is(reqId.get())); @@ -983,7 +993,7 @@ public void testWrongTokenDoesNotFallbackToAnonymous() { final AnonymousUser anonymousUser = new AnonymousUser(anonymousEnabledSettings); service = new AuthenticationService(anonymousEnabledSettings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { boolean requestIdAlreadyPresent = randomBoolean(); @@ -1017,7 +1027,7 @@ public void testWrongApiKeyDoesNotFallbackToAnonymous() { final AnonymousUser anonymousUser = new AnonymousUser(anonymousEnabledSettings); service = new AuthenticationService(anonymousEnabledSettings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, - tokenService, apiKeyService, operatorPrivilegesService); + tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); doAnswer(invocationOnMock -> { final GetRequest request = (GetRequest) invocationOnMock.getArguments()[0]; final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; @@ -1058,7 +1068,7 @@ public void testAnonymousUserRest() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); + threadPool, anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); RestRequest request = new FakeRestRequest(); Tuple result = authenticateBlocking(request); @@ -1085,7 +1095,7 @@ public void testAuthenticateRestRequestDisallowAnonymous() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); + threadPool, anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); RestRequest request = new FakeRestRequest(); PlainActionFuture future = new PlainActionFuture<>(); @@ -1109,7 +1119,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); + threadPool, anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); InternalRequest message = new InternalRequest(); boolean requestIdAlreadyPresent = randomBoolean(); SetOnce reqId = new SetOnce<>(); @@ -1138,7 +1148,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); + threadPool, anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService); InternalRequest message = new InternalRequest(); boolean requestIdAlreadyPresent = randomBoolean(); @@ -1897,6 +1907,43 @@ public void testExpiredApiKey() { } } + public void testCanAuthenticateServiceAccount() throws ExecutionException, InterruptedException { + Mockito.reset(serviceAccountService); + final Authentication authentication = new Authentication( + new User("elastic/fleet-server"), + new RealmRef("service_account", "service_account", "foo"), null); + try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(authentication); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); + final PlainActionFuture future = new PlainActionFuture<>(); + service.authenticate("_action", transportRequest, false, future); + assertThat(future.get(), is(authentication)); + } + } + + public void testServiceAccountFailureWillNotFallthrough() { + Mockito.reset(serviceAccountService); + final RuntimeException bailOut = new RuntimeException("bail out"); + try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onFailure(bailOut); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); + final PlainActionFuture future = new PlainActionFuture<>(); + service.authenticate("_action", transportRequest, false, future); + final ExecutionException e = expectThrows(ExecutionException.class, () -> future.get()); + assertThat(e.getCause().getCause(), is(bailOut)); + } + } + private static class InternalRequest extends TransportRequest { @Override public void writeTo(StreamOutput out) {} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java index 14d20236c9c20..226a80d5c5d73 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java @@ -46,6 +46,7 @@ import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.text.Text; import org.elasticsearch.common.unit.TimeValue; @@ -241,7 +242,8 @@ public void testAttachAndGetToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -251,7 +253,8 @@ public void testAttachAndGetToken() throws Exception { TokenService anotherService = createTokenService(tokenServiceEnabledSettings, systemUTC()); anotherService.refreshMetadata(tokenService.getTokenMetadata()); PlainActionFuture future = new PlainActionFuture<>(); - anotherService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = anotherService.extractBearerTokenFromHeader(requestContext); + anotherService.tryAuthenticateToken(bearerToken, future); UserToken fromOtherService = future.get(); assertAuthentication(authentication, fromOtherService.getAuthentication()); } @@ -266,7 +269,8 @@ public void testInvalidAuthorizationHeader() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertThat(serialized, nullValue()); } @@ -292,7 +296,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -300,7 +305,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -320,7 +326,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -359,7 +366,8 @@ public void testKeyExchange() throws Exception { storeTokenHeader(requestContext, accessToken); try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - otherTokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = otherTokenService.extractBearerTokenFromHeader(requestContext); + otherTokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(serialized.getAuthentication(), authentication); } @@ -370,7 +378,8 @@ public void testKeyExchange() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - otherTokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = otherTokenService.extractBearerTokenFromHeader(requestContext); + otherTokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(serialized.getAuthentication(), authentication); } @@ -396,7 +405,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -410,7 +420,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -429,7 +440,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertNull(serialized); } @@ -439,7 +451,8 @@ public void testPruneKeys() throws Exception { mockGetTokenFromId(tokenService, newUserTokenId, authentication, false); try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -466,7 +479,8 @@ public void testPassphraseWorks() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -475,7 +489,8 @@ public void testPassphraseWorks() throws Exception { // verify a second separate token service with its own passphrase cannot verify TokenService anotherService = createTokenService(tokenServiceEnabledSettings, systemUTC()); PlainActionFuture future = new PlainActionFuture<>(); - anotherService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = anotherService.extractBearerTokenFromHeader(requestContext); + anotherService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -519,7 +534,8 @@ public void testInvalidatedToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); final String headerValue = e.getHeader("WWW-Authenticate").get(0); assertThat(headerValue, containsString("Bearer realm=")); @@ -635,7 +651,8 @@ public void testTokenExpiry() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { // the clock is still frozen, so the cookie should be valid PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -645,7 +662,8 @@ public void testTokenExpiry() throws Exception { // move the clock forward but don't go to expiry clock.fastForwardSeconds(fastForwardAmount); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -653,7 +671,8 @@ public void testTokenExpiry() throws Exception { // move to expiry, stripping nanoseconds, as we don't store them in the security-tokens index clock.setTime(userToken.getExpirationTime().truncatedTo(ChronoUnit.MILLIS).atZone(clock.getZone())); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -661,7 +680,8 @@ public void testTokenExpiry() throws Exception { // move one second past expiry clock.fastForwardSeconds(1); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); final String headerValue = e.getHeader("WWW-Authenticate").get(0); assertThat(headerValue, containsString("Bearer realm=")); @@ -682,7 +702,7 @@ public void testTokenServiceDisabled() throws Exception { assertThat(e.getMetadata(FeatureNotEnabledException.DISABLED_FEATURE_METADATA), contains("security_tokens")); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(null, future); + tokenService.tryAuthenticateToken(null, future); assertNull(future.get()); PlainActionFuture invalidateFuture = new PlainActionFuture<>(); @@ -728,7 +748,8 @@ public void testMalformedToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -743,7 +764,8 @@ public void testNotValidPre72Tokens() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -758,7 +780,8 @@ public void testNotValidAfter72Tokens() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -796,26 +819,30 @@ public void testIndexNotAvailable() throws Exception { } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken3 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken3, future); assertNull(future.get()); when(tokensIndex.isAvailable()).thenReturn(false); when(tokensIndex.getUnavailableReason()).thenReturn(new UnavailableShardsException(null, "unavailable")); when(tokensIndex.indexExists()).thenReturn(true); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken2 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken2, future); assertNull(future.get()); when(tokensIndex.indexExists()).thenReturn(false); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken1 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken1, future); assertNull(future.get()); when(tokensIndex.isAvailable()).thenReturn(true); when(tokensIndex.indexExists()).thenReturn(true); mockGetTokenFromId(tokenService, userTokenId, authentication, false); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(future.get().getAuthentication(), authentication); } } @@ -878,7 +905,8 @@ public void testCannotValidateTokenIfLicenseDoesNotAllowTokens() throws Exceptio PlainActionFuture authFuture = new PlainActionFuture<>(); when(licenseState.checkFeature(Feature.SECURITY_TOKEN_SERVICE)).thenReturn(false); - tokenService.getAndValidateToken(threadContext, authFuture); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(threadContext); + tokenService.tryAuthenticateToken(bearerToken, authFuture); UserToken authToken = authFuture.actionGet(); assertThat(authToken, Matchers.nullValue()); } @@ -1068,5 +1096,4 @@ private String generateAccessToken(TokenService tokenService, Version version) t } return tokenService.prependVersionAndEncodeAccessToken(version, accessTokenString); } - } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..0d30603d30e18 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java @@ -0,0 +1,161 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ListenableFuture; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.junit.After; +import org.junit.Before; + +import java.util.Collection; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; + +public class CachingServiceAccountsTokenStoreTests extends ESTestCase { + + private Settings globalSettings; + private ThreadPool threadPool; + + @Before + public void init() { + globalSettings = Settings.builder().put("path.home", createTempDir()).build(); + threadPool = new TestThreadPool("test"); + } + + @After + public void stop() { + if (threadPool != null) { + terminate(threadPool); + } + } + + public void testCache() throws ExecutionException, InterruptedException { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final SecureString validSecret = new SecureString("super-secret-value".toCharArray()); + final SecureString invalidSecret = new SecureString("some-fishy-value".toCharArray()); + final ServiceAccountToken token1Valid = new ServiceAccountToken(accountId, "token1", validSecret); + final ServiceAccountToken token1Invalid = new ServiceAccountToken(accountId, "token1", invalidSecret); + final ServiceAccountToken token2Valid = new ServiceAccountToken(accountId, "token2", validSecret); + final ServiceAccountToken token2Invalid = new ServiceAccountToken(accountId, "token2", invalidSecret); + final AtomicBoolean doAuthenticateInvoked = new AtomicBoolean(false); + + final CachingServiceAccountsTokenStore store = new CachingServiceAccountsTokenStore(globalSettings, threadPool) { + @Override + void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + doAuthenticateInvoked.set(true); + listener.onResponse(validSecret.equals(token.getSecret())); + } + + @Override + public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + listener.onFailure(new UnsupportedOperationException()); + } + }; + + final Cache> cache = store.getCache(); + assertThat(cache.count(), equalTo(0)); + + // 1st auth with the right token1 + final PlainActionFuture future1 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future1); + assertThat(future1.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(1)); + doAuthenticateInvoked.set(false); // reset + + // 2nd auth with the right token1 should use cache + final PlainActionFuture future2 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future2); + assertThat(future2.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 3rd auth with the wrong token1 that has the same qualified name should use cache + final PlainActionFuture future3 = new PlainActionFuture<>(); + store.authenticate(token1Invalid, future3); + assertThat(future3.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 4th auth with the wrong token2 + final PlainActionFuture future4 = new PlainActionFuture<>(); + store.authenticate(token2Invalid, future4); + assertThat(future4.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // 5th auth with the wrong token2 again should use cache + final PlainActionFuture future5 = new PlainActionFuture<>(); + store.authenticate(token2Invalid, future5); + assertThat(future5.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 6th auth with the right token2 + final PlainActionFuture future6 = new PlainActionFuture<>(); + store.authenticate(token2Valid, future6); + assertThat(future6.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // Invalidate token1 in the cache + store.invalidate(org.elasticsearch.common.collect.List.of(token1Valid.getQualifiedName())); + assertThat(cache.count(), equalTo(1)); + + // 7th auth with the right token1 + final PlainActionFuture future7 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future7); + assertThat(future7.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // Invalidate all items in the cache + store.invalidateAll(); + assertThat(cache.count(), equalTo(0)); + } + + public void testCacheCanBeDisabled() throws ExecutionException, InterruptedException { + final Settings settings = Settings.builder() + .put(globalSettings) + .put(CachingServiceAccountsTokenStore.CACHE_TTL_SETTING.getKey(), "0") + .build(); + + final boolean success = randomBoolean(); + + final CachingServiceAccountsTokenStore store = new CachingServiceAccountsTokenStore(settings, threadPool) { + @Override + void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + listener.onResponse(success); + } + + @Override + public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + listener.onFailure(new UnsupportedOperationException()); + } + }; + assertThat(store.getCache(), nullValue()); + // authenticate should still work + final PlainActionFuture future = new PlainActionFuture<>(); + store.authenticate(mock(ServiceAccountToken.class), future); + assertThat(future.get(), is(success)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..cdea41903f886 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java @@ -0,0 +1,180 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.junit.Before; +import org.mockito.Mockito; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +public class CompositeServiceAccountsTokenStoreTests extends ESTestCase { + + private ThreadContext threadContext; + private ServiceAccountsTokenStore store1; + private ServiceAccountsTokenStore store2; + private ServiceAccountsTokenStore store3; + private CompositeServiceAccountsTokenStore compositeStore; + + @Before + public void init() { + threadContext = new ThreadContext(Settings.EMPTY); + store1 = mock(ServiceAccountsTokenStore.class); + store2 = mock(ServiceAccountsTokenStore.class); + store3 = mock(ServiceAccountsTokenStore.class); + compositeStore = new CompositeServiceAccountsTokenStore( + org.elasticsearch.common.collect.List.of(store1, store2, store3), threadContext); + } + + public void testAuthenticate() throws ExecutionException, InterruptedException { + Mockito.reset(store1, store2, store3); + + final ServiceAccountToken token = mock(ServiceAccountToken.class); + final boolean store1Success = randomBoolean(); + final boolean store2Success = randomBoolean(); + final boolean store3Success = randomBoolean(); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store1Success); + return null; + }).when(store1).authenticate(eq(token), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store2Success); + return null; + }).when(store2).authenticate(eq(token), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store3Success); + return null; + }).when(store3).authenticate(eq(token), any()); + + final PlainActionFuture future = new PlainActionFuture<>(); + compositeStore.authenticate(token, future); + if (store1Success || store2Success || store3Success) { + assertThat(future.get(), is(true)); + if (store1Success) { + verify(store1).authenticate(eq(token), any()); + verifyZeroInteractions(store2); + verifyZeroInteractions(store3); + } else if (store2Success) { + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verifyZeroInteractions(store3); + } else { + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verify(store3).authenticate(eq(token), any()); + } + } else { + assertThat(future.get(), is(false)); + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verify(store3).authenticate(eq(token), any()); + } + } + + public void testFindTokensFor() throws ExecutionException, InterruptedException { + Mockito.reset(store1, store2, store3); + + final ServiceAccountId accountId1 = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final ServiceAccountId accountId2 = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final boolean store1Error = randomBoolean(); + final RuntimeException e = new RuntimeException("fail"); + final Set allTokenInfos = new HashSet<>(); + + doAnswer(invocationOnMock -> { + final ServiceAccountId accountId = (ServiceAccountId) invocationOnMock.getArguments()[0]; + @SuppressWarnings("unchecked") + final ActionListener> listener = + (ActionListener>) invocationOnMock.getArguments()[1]; + if (accountId == accountId1) { + final Set tokenInfos = new HashSet<>(); + IntStream.range(0, randomIntBetween(0, 5)).forEach(i -> { + final TokenInfo tokenInfo = TokenInfo.fileToken(randomAlphaOfLengthBetween(3, 8)); + tokenInfos.add(tokenInfo); + }); + allTokenInfos.addAll(tokenInfos); + listener.onResponse(tokenInfos); + } else { + if (store1Error) { + listener.onFailure(e); + } else { + listener.onResponse(org.elasticsearch.common.collect.List.of()); + } + } + return null; + }).when(store1).findTokensFor(any(), any()); + + doAnswer(invocationOnMock -> { + final ServiceAccountId accountId = (ServiceAccountId) invocationOnMock.getArguments()[0]; + @SuppressWarnings("unchecked") + final ActionListener> listener = + (ActionListener>) invocationOnMock.getArguments()[1]; + if (accountId == accountId1) { + final Set tokenInfos = new HashSet<>(); + IntStream.range(0, randomIntBetween(0, 5)).forEach(i -> { + final TokenInfo tokenInfo = TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8)); + tokenInfos.add(tokenInfo); + }); + allTokenInfos.addAll(tokenInfos); + listener.onResponse(tokenInfos); + } else { + if (store1Error) { + listener.onResponse(org.elasticsearch.common.collect.List.of()); + } else { + listener.onFailure(e); + } + } + return null; + }).when(store2).findTokensFor(any(), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener> listener = + (ActionListener>) invocationOnMock.getArguments()[1]; + listener.onResponse(org.elasticsearch.common.collect.List.of()); + return null; + }).when(store3).findTokensFor(any(), any()); + + final PlainActionFuture> future1 = new PlainActionFuture<>(); + compositeStore.findTokensFor(accountId1, future1); + final Collection result = future1.get(); + assertThat(new HashSet<>(result), equalTo(allTokenInfos)); + + final PlainActionFuture> future2 = new PlainActionFuture<>(); + compositeStore.findTokensFor(accountId2, future2); + final RuntimeException e2 = expectThrows(RuntimeException.class, future2::actionGet); + assertThat(e2, is(e)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java new file mode 100644 index 0000000000000..78ab4e3688614 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java @@ -0,0 +1,71 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.permission.Role; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ElasticServiceAccount; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; + +public class ElasticServiceAccountsTests extends ESTestCase { + + public void testElasticFleetPrivileges() { + final Role role = Role.builder(ElasticServiceAccounts.ACCOUNTS.get("elastic/fleet-server").roleDescriptor(), null).build(); + final Authentication authentication = mock(Authentication.class); + assertThat(role.cluster().check(CreateApiKeyAction.NAME, + new CreateApiKeyRequest(randomAlphaOfLengthBetween(3, 8), null, null), authentication), is(true)); + assertThat(role.cluster().check(GetApiKeyAction.NAME, GetApiKeyRequest.forOwnedApiKeys(), authentication), is(true)); + assertThat(role.cluster().check(InvalidateApiKeyAction.NAME, InvalidateApiKeyRequest.forOwnedApiKeys(), authentication), is(true)); + + assertThat(role.cluster().check(GetApiKeyAction.NAME, randomFrom(GetApiKeyRequest.forAllApiKeys()), authentication), is(false)); + assertThat(role.cluster().check(InvalidateApiKeyAction.NAME, + InvalidateApiKeyRequest.usingUserName(randomAlphaOfLengthBetween(3, 16)), authentication), is(false)); + + // TODO: more tests when role descriptor is finalised for elastic/fleet-server + } + + public void testElasticServiceAccount() { + final String serviceName = randomAlphaOfLengthBetween(3, 8); + final String principal = ElasticServiceAccounts.NAMESPACE + "/" + serviceName; + final RoleDescriptor roleDescriptor1 = new RoleDescriptor(principal, null, null, null); + final ElasticServiceAccount serviceAccount = new ElasticServiceAccount( + serviceName, roleDescriptor1); + assertThat(serviceAccount.id(), equalTo(new ServiceAccount.ServiceAccountId(ElasticServiceAccounts.NAMESPACE, serviceName))); + assertThat(serviceAccount.roleDescriptor(), equalTo(roleDescriptor1)); + assertThat(serviceAccount.asUser(), equalTo(new User(principal, Strings.EMPTY_ARRAY, + "Service account - " + principal, null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), + true))); + + final NullPointerException e1 = + expectThrows(NullPointerException.class, () -> new ElasticServiceAccount(serviceName, null)); + assertThat(e1.getMessage(), containsString("Role descriptor cannot be null")); + + final RoleDescriptor roleDescriptor2 = new RoleDescriptor(randomAlphaOfLengthBetween(6, 16), + null, null, null); + final IllegalArgumentException e2 = + expectThrows(IllegalArgumentException.class, () -> new ElasticServiceAccount(serviceName, roleDescriptor2)); + assertThat(e2.getMessage(), containsString( + "the provided role descriptor [" + roleDescriptor2.getName() + + "] must have the same name as the service account [" + principal + "]")); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..039f4cfefec2f --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java @@ -0,0 +1,204 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.core.security.audit.logfile.CapturingLogger; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.junit.After; +import org.junit.Before; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; + +public class FileServiceAccountsTokenStoreTests extends ESTestCase { + + private static Map TOKENS = org.elasticsearch.common.collect.Map.of( + "bcrypt", "46ToAwIHZWxhc3RpYwVmbGVldAZiY3J5cHQWWEU5MGVBYW9UMWlXMVctdkpmMzRxdwAAAAAAAAA", + "bcrypt10", "46ToAwIHZWxhc3RpYwVmbGVldAhiY3J5cHQxMBY1MmVqWGxhelJCYWZMdXpHTTVoRmNnAAAAAAAAAAAAAAAAAA", + "pbkdf2", "46ToAwIHZWxhc3RpYwVmbGVldAZwYmtkZjIWNURqUkNfWFJTQXFsNUhsYW1weXY3UQAAAAAAAAA", + "pbkdf2_50000", "46ToAwIHZWxhc3RpYwVmbGVldAxwYmtkZjJfNTAwMDAWd24wWGZ4NUlSSHkybE9LU2N2ZndyZwAAAAAAAAAAAA", + "pbkdf2_stretch", "46ToAwIHZWxhc3RpYwVmbGVldA5wYmtkZjJfc3RyZXRjaBZhSV8wUUxSZlJ5R0JQMVU2MFNieTJ3AAAAAAAAAA" + ); + + private Settings settings; + private Environment env; + private ThreadPool threadPool; + + @Before + public void init() { + final String hashingAlgorithm = inFipsJvm() ? randomFrom("pbkdf2", "pbkdf2_50000", "pbkdf2_stretch") : + randomFrom("bcrypt", "bcrypt10", "pbkdf2", "pbkdf2_50000", "pbkdf2_stretch"); + settings = Settings.builder() + .put("resource.reload.interval.high", "100ms") + .put("path.home", createTempDir()) + .put("xpack.security.authc.service_token_hashing.algorithm", hashingAlgorithm) + .build(); + env = TestEnvironment.newEnvironment(settings); + threadPool = new TestThreadPool("test"); + } + + @After + public void shutdown() { + terminate(threadPool); + } + + public void testParseFile() throws Exception { + Path path = getDataPath("service_tokens"); + Map parsedTokenHashes = FileServiceAccountsTokenStore.parseFile(path, null); + assertThat(parsedTokenHashes, notNullValue()); + assertThat(parsedTokenHashes.size(), is(5)); + + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/bcrypt")), + equalTo("$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i")); + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/bcrypt10")), + equalTo("$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe")); + + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/pbkdf2")), + equalTo("{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo=")); + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/pbkdf2_50000")), + equalTo("{PBKDF2}50000$IMzlphNClmrP/du40yxGM3fNjklg8CuACds12+Ry0jM=$KEC1S9a0NOs3OJKM4gEeBboU18EP4+3m/pyIA4MBDGk=")); + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/pbkdf2_stretch")), + equalTo("{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o=")); + + assertThat(parsedTokenHashes.get("elastic/fleet-server/plain"), nullValue()); + } + + public void testParseFileNotExists() throws IllegalAccessException, IOException { + Logger logger = CapturingLogger.newCapturingLogger(Level.TRACE, null); + final Map tokenHashes = + FileServiceAccountsTokenStore.parseFile(getDataPath("service_tokens").getParent().resolve("does-not-exist"), logger); + assertThat(tokenHashes.isEmpty(), is(true)); + final List events = CapturingLogger.output(logger.getName(), Level.TRACE); + assertThat(events.size(), equalTo(2)); + assertThat(events.get(1), containsString("does not exist")); + } + + public void testAutoReload() throws Exception { + Path serviceTokensSourceFile = getDataPath("service_tokens"); + Path configDir = env.configFile(); + Files.createDirectories(configDir); + Path targetFile = configDir.resolve("service_tokens"); + Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + final Hasher hasher = Hasher.resolve(settings.get("xpack.security.authc.service_token_hashing.algorithm")); + try (ResourceWatcherService watcherService = new ResourceWatcherService(settings, threadPool)) { + final CountDownLatch latch = new CountDownLatch(5); + + FileServiceAccountsTokenStore store = new FileServiceAccountsTokenStore(env, watcherService, threadPool); + store.addListener(latch::countDown); + //Token name shares the hashing algorithm name for convenience + String tokenName = settings.get("xpack.security.authc.service_token_hashing.algorithm"); + final String qualifiedTokenName = "elastic/fleet-server/" + tokenName; + assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true)); + + // A blank line should not trigger update + try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.append("\n"); + } + watcherService.notifyNow(ResourceWatcherService.Frequency.HIGH); + if (latch.getCount() != 5) { + fail("Listener should not be called as service tokens are not changed."); + } + assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true)); + + // Add a new entry + final char[] newTokenHash = + hasher.hash(new SecureString("46ToAwIHZWxhc3RpYwVmbGVldAZ0b2tlbjEWWkYtQ3dlWlVTZldJX3p5Vk9ySnlSQQAAAAAAAAA".toCharArray())); + try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.newLine(); + writer.append("elastic/fleet-server/token1:").append(new String(newTokenHash)); + } + assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 4, latch.getCount()), + 5, TimeUnit.SECONDS); + assertThat(store.getTokenHashes().containsKey("elastic/fleet-server/token1"), is(true)); + + // Remove the new entry + Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 3, latch.getCount()), + 5, TimeUnit.SECONDS); + assertThat(store.getTokenHashes().containsKey("elastic/fleet-server/token1"), is(false)); + assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true)); + + // Write a mal-formatted line + if (randomBoolean()) { + try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.newLine(); + writer.append("elastic/fleet-server/tokenxfoobar"); + } + } else { + // writing in utf_16 should cause a parsing error as we try to read the file in utf_8 + try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_16, StandardOpenOption.APPEND)) { + writer.newLine(); + writer.append("elastic/fleet-server/tokenx:").append(new String(newTokenHash)); + } + } + assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 2, latch.getCount()), + 5, TimeUnit.SECONDS); + assertThat(store.getTokenHashes().isEmpty(), is(true)); + + // Restore to original file again + Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 1, latch.getCount()), + 5, TimeUnit.SECONDS); + assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true)); + + // Duplicate entry + try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.newLine(); + writer.append(qualifiedTokenName + ":").append(new String(newTokenHash)); + } + assertBusy(() -> assertEquals("Waited too long for the updated file to be picked up", 0, latch.getCount()), + 5, TimeUnit.SECONDS); + assertThat(store.getTokenHashes().get(qualifiedTokenName), equalTo(newTokenHash)); + } + } + + public void testFindTokensFor() throws IOException { + Path serviceTokensSourceFile = getDataPath("service_tokens"); + Path configDir = env.configFile(); + Files.createDirectories(configDir); + Path targetFile = configDir.resolve("service_tokens"); + Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + FileServiceAccountsTokenStore store = new FileServiceAccountsTokenStore(env, mock(ResourceWatcherService.class), threadPool); + + final ServiceAccountId accountId = new ServiceAccountId("elastic", "fleet-server"); + final PlainActionFuture> future1 = new PlainActionFuture<>(); + store.findTokensFor(accountId, future1); + final Collection tokenInfos1 = future1.actionGet(); + assertThat(tokenInfos1.size(), equalTo(5)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..0480d330584af --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStoreTests.java @@ -0,0 +1,302 @@ +/* + * 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.security.authc.service; + +import org.apache.lucene.search.TotalHits; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.DocWriteRequest.OpType; +import org.elasticsearch.action.bulk.BulkItemResponse; +import org.elasticsearch.action.bulk.BulkRequest; +import org.elasticsearch.action.bulk.BulkResponse; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexResponse; +import org.elasticsearch.action.search.ClearScrollRequest; +import org.elasticsearch.action.search.ClearScrollResponse; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.client.Client; +import org.elasticsearch.client.FilterClient; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.node.DiscoveryNodes; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.get.GetResult; +import org.elasticsearch.index.shard.ShardId; +import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchHits; +import org.elasticsearch.search.internal.InternalSearchResponse; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.XContentTestUtils; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; +import org.junit.Before; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.elasticsearch.index.seqno.SequenceNumbers.UNASSIGNED_PRIMARY_TERM; +import static org.elasticsearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; +import static org.elasticsearch.xpack.security.authc.service.IndexServiceAccountsTokenStore.SERVICE_ACCOUNT_TOKEN_DOC_TYPE; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IndexServiceAccountsTokenStoreTests extends ESTestCase { + + private Client client; + private ClusterService clusterService; + private CacheInvalidatorRegistry cacheInvalidatorRegistry; + private IndexServiceAccountsTokenStore store; + private final AtomicReference requestHolder = new AtomicReference<>(); + private final AtomicReference>> responseProviderHolder = + new AtomicReference<>(); + + @Before + public void init() { + Client mockClient = mock(Client.class); + when(mockClient.settings()).thenReturn(Settings.EMPTY); + ThreadPool threadPool = mock(ThreadPool.class); + when(mockClient.threadPool()).thenReturn(threadPool); + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + client = new FilterClient(mockClient) { + @Override + protected + void doExecute(ActionType action, Request request, ActionListener listener) { + requestHolder.set(request); + responseProviderHolder.get().accept(request, (ActionListener) listener); + } + }; + clusterService = mock(ClusterService.class); + final ClusterState clusterState = mock(ClusterState.class); + final DiscoveryNodes discoveryNodes = mock(DiscoveryNodes.class); + when(discoveryNodes.getMinNodeVersion()).thenReturn(Version.CURRENT); + when(clusterState.nodes()).thenReturn(discoveryNodes); + when(clusterService.state()).thenReturn(clusterState); + cacheInvalidatorRegistry = mock(CacheInvalidatorRegistry.class); + + SecurityIndexManager securityIndex = mock(SecurityIndexManager.class); + when(securityIndex.isAvailable()).thenReturn(true); + when(securityIndex.indexExists()).thenReturn(true); + when(securityIndex.isIndexUpToDate()).thenReturn(true); + when(securityIndex.freeze()).thenReturn(securityIndex); + doAnswer((i) -> { + Runnable action = (Runnable) i.getArguments()[1]; + action.run(); + return null; + }).when(securityIndex).prepareIndexIfNeededThenExecute(any(Consumer.class), any(Runnable.class)); + doAnswer((i) -> { + Runnable action = (Runnable) i.getArguments()[1]; + action.run(); + return null; + }).when(securityIndex).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); + store = new IndexServiceAccountsTokenStore(Settings.EMPTY, + threadPool, + Clock.systemUTC(), + client, + securityIndex, + clusterService, + cacheInvalidatorRegistry); + } + + public void testDoAuthenticate() throws IOException, ExecutionException, InterruptedException, IllegalAccessException { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final ServiceAccountToken serviceAccountToken = ServiceAccountToken.newToken(accountId, randomAlphaOfLengthBetween(3, 8)); + final GetResponse getResponse1 = createGetResponse(serviceAccountToken, true); + + // success + responseProviderHolder.set((r, l) -> l.onResponse(getResponse1)); + final PlainActionFuture future1 = new PlainActionFuture<>(); + store.doAuthenticate(serviceAccountToken, future1); + final GetRequest getRequest = (GetRequest) requestHolder.get(); + assertThat(getRequest.id(), equalTo("service_account_token-" + serviceAccountToken.getQualifiedName())); + assertThat(future1.get(), is(true)); + + // token mismatch + final GetResponse getResponse2 = createGetResponse(ServiceAccountToken.newToken(accountId, randomAlphaOfLengthBetween(3, 8)), true); + responseProviderHolder.set((r, l) -> l.onResponse(getResponse2)); + final PlainActionFuture future2 = new PlainActionFuture<>(); + store.doAuthenticate(serviceAccountToken, future2); + assertThat(future2.get(), is(false)); + + // token document not found + final GetResponse getResponse3 = createGetResponse(serviceAccountToken, false); + responseProviderHolder.set((r, l) -> l.onResponse(getResponse3)); + final PlainActionFuture future3 = new PlainActionFuture<>(); + store.doAuthenticate(serviceAccountToken, future3); + assertThat(future3.get(), is(false)); + } + + public void testCreateToken() throws ExecutionException, InterruptedException { + final Authentication authentication = createAuthentication(); + final CreateServiceAccountTokenRequest request = + new CreateServiceAccountTokenRequest("elastic", "fleet-server", randomAlphaOfLengthBetween(3, 8)); + + // created + responseProviderHolder.set((r, l) -> l.onResponse(createSingleBulkResponse())); + final PlainActionFuture future1 = new PlainActionFuture<>(); + store.createToken(authentication, request, future1); + final BulkRequest bulkRequest = (BulkRequest) requestHolder.get(); + assertThat(bulkRequest.requests().size(), equalTo(1)); + final IndexRequest indexRequest = (IndexRequest) bulkRequest.requests().get(0); + final Map sourceMap = indexRequest.sourceAsMap(); + assertThat(sourceMap.get("username"), equalTo("elastic/fleet-server")); + assertThat(sourceMap.get("name"), equalTo(request.getTokenName())); + assertThat(sourceMap.get("doc_type"), equalTo("service_account_token")); + assertThat(sourceMap.get("version"), equalTo(Version.CURRENT.id)); + assertThat(sourceMap.get("password"), notNullValue()); + assertThat(Hasher.resolveFromHash(((String) sourceMap.get("password")).toCharArray()), equalTo(Hasher.PBKDF2_STRETCH)); + assertThat(sourceMap.get("creation_time"), notNullValue()); + @SuppressWarnings("unchecked") + final Map creatorMap = (Map) sourceMap.get("creator"); + assertThat(creatorMap, notNullValue()); + assertThat(creatorMap.get("principal"), equalTo(authentication.getUser().principal())); + assertThat(creatorMap.get("full_name"), equalTo(authentication.getUser().fullName())); + assertThat(creatorMap.get("email"), equalTo(authentication.getUser().email())); + assertThat(creatorMap.get("metadata"), equalTo(authentication.getUser().metadata())); + assertThat(creatorMap.get("realm"), equalTo(authentication.getSourceRealm().getName())); + assertThat(creatorMap.get("realm_type"), equalTo(authentication.getSourceRealm().getType())); + + final CreateServiceAccountTokenResponse createServiceAccountTokenResponse1 = future1.get(); + assertNotNull(createServiceAccountTokenResponse1); + assertThat(createServiceAccountTokenResponse1.getName(), equalTo(request.getTokenName())); + assertNotNull(createServiceAccountTokenResponse1.getValue()); + + // failure + final Exception exception = mock(Exception.class); + responseProviderHolder.set((r, l) -> l.onFailure(exception)); + final PlainActionFuture future3 = new PlainActionFuture<>(); + store.createToken(authentication, request, future3); + final ExecutionException e3 = expectThrows(ExecutionException.class, () -> future3.get()); + assertThat(e3.getCause(), is(exception)); + } + + public void testCreateTokenWillFailForInvalidServiceAccount() { + final Authentication authentication = createAuthentication(); + final CreateServiceAccountTokenRequest request = randomValueOtherThanMany( + r -> "elastic".equals(r.getNamespace()) && "fleet-server".equals(r.getServiceName()), + () -> new CreateServiceAccountTokenRequest(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8))); + final PlainActionFuture future = new PlainActionFuture<>(); + store.createToken(authentication, request, future); + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, future::actionGet); + assertThat(e.getMessage(), + containsString("service account [" + request.getNamespace() + "/" + request.getServiceName() + "] does not exist")); + } + + public void testFindTokensFor() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final int nhits = randomIntBetween(0, 10); + final String[] tokenNames = randomArray(nhits, nhits, String[]::new, ServiceAccountTokenTests::randomTokenName); + + responseProviderHolder.set((r, l) -> { + if (r instanceof SearchRequest) { + final SearchHit[] hits = IntStream.range(0, nhits) + .mapToObj(i -> + new SearchHit(randomIntBetween(0, Integer.MAX_VALUE), + SERVICE_ACCOUNT_TOKEN_DOC_TYPE + "-" + accountId.asPrincipal() + "/" + tokenNames[i], null, + org.elasticsearch.common.collect.Map.of(), org.elasticsearch.common.collect.Map.of())) + .toArray(SearchHit[]::new); + final InternalSearchResponse internalSearchResponse; + internalSearchResponse = new InternalSearchResponse(new SearchHits(hits, + new TotalHits(nhits, TotalHits.Relation.EQUAL_TO), + randomFloat(), null, null, null), + null, null, null, false, null, 0); + + final SearchResponse searchResponse = + new SearchResponse(internalSearchResponse, randomAlphaOfLengthBetween(3, 8), + 1, 1, 0, 10, null, null); + l.onResponse(searchResponse); + } else if (r instanceof ClearScrollRequest) { + l.onResponse(new ClearScrollResponse(true, 1)); + } + }); + + final PlainActionFuture> future = new PlainActionFuture<>(); + store.findTokensFor(accountId, future); + final Collection tokenInfos = future.actionGet(); + assertThat(tokenInfos.stream().map(TokenInfo::getSource).allMatch(TokenInfo.TokenSource.INDEX::equals), is(true)); + assertThat(tokenInfos.stream().map(TokenInfo::getName).collect(Collectors.toSet()), + equalTo(org.elasticsearch.common.collect.Set.of(tokenNames))); + } + + public void testFindTokensForException() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final RuntimeException e = new RuntimeException("fail"); + responseProviderHolder.set((r, l) -> { + l.onFailure(e); + }); + + final PlainActionFuture> future = new PlainActionFuture<>(); + store.findTokensFor(accountId, future); + final RuntimeException e1 = expectThrows(RuntimeException.class, future::actionGet); + assertThat(e1, is(e)); + } + + private GetResponse createGetResponse(ServiceAccountToken serviceAccountToken, boolean exists) throws IOException { + final char[] hash = Hasher.PBKDF2_STRETCH.hash(serviceAccountToken.getSecret()); + final Map documentMap + = org.elasticsearch.common.collect.Map.of("password", new String(CharArrays.toUtf8Bytes(hash), StandardCharsets.UTF_8)); + return new GetResponse(new GetResult( + randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8), + exists ? randomLongBetween(0, Long.MAX_VALUE) : UNASSIGNED_SEQ_NO, + exists ? randomLongBetween(1, Long.MAX_VALUE) : UNASSIGNED_PRIMARY_TERM, randomLong(), exists, + XContentTestUtils.convertToXContent(documentMap, XContentType.JSON), + org.elasticsearch.common.collect.Map.of(), org.elasticsearch.common.collect.Map.of())); + } + + private Authentication createAuthentication() { + return new Authentication(new User(randomAlphaOfLengthBetween(3, 8)), + new Authentication.RealmRef(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8)), + randomFrom(new Authentication.RealmRef(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8)), null)); + } + + private BulkResponse createSingleBulkResponse() { + return new BulkResponse(new BulkItemResponse[] { + new BulkItemResponse(randomInt(), OpType.CREATE, new IndexResponse( + mock(ShardId.class), randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), randomLong(), randomLong(), randomLong(), true + )) + }, randomLong()); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIdTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIdTests.java new file mode 100644 index 0000000000000..2665a0b0d1e7f --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIdTests.java @@ -0,0 +1,78 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class ServiceAccountIdTests extends ESTestCase { + + public void testFromPrincipalAndInstantiate() { + final String namespace1 = randomAlphaOfLengthBetween(3, 8); + final String serviceName1 = randomAlphaOfLengthBetween(3, 8); + final String principal1 = namespace1 + "/" + serviceName1; + + final ServiceAccount.ServiceAccountId accountId1; + if (randomBoolean()) { + accountId1 = ServiceAccount.ServiceAccountId.fromPrincipal(principal1); + } else { + accountId1 = new ServiceAccount.ServiceAccountId(namespace1, serviceName1); + } + assertThat(accountId1.asPrincipal(), equalTo(principal1)); + assertThat(accountId1.namespace(), equalTo(namespace1)); + assertThat(accountId1.serviceName(), equalTo(serviceName1)); + + // No '/' + final String principal2 = randomAlphaOfLengthBetween(6, 16); + final IllegalArgumentException e2 = + expectThrows(IllegalArgumentException.class, () -> ServiceAccount.ServiceAccountId.fromPrincipal(principal2)); + assertThat(e2.getMessage(), containsString( + "a service account ID must be in the form {namespace}/{service-name}, but was [" + principal2 + "]")); + + // blank namespace + final IllegalArgumentException e3; + if (randomBoolean()) { + e3 = expectThrows(IllegalArgumentException.class, + () -> ServiceAccount.ServiceAccountId.fromPrincipal( + randomFrom("", " ", "\t", " \t") + "/" + randomAlphaOfLengthBetween(3, 8))); + } else { + e3 = expectThrows(IllegalArgumentException.class, + () -> new ServiceAccount.ServiceAccountId(randomFrom("", " ", "\t", " \t", null), randomAlphaOfLengthBetween(3, 8))); + } + assertThat(e3.getMessage(), containsString("the namespace of a service account ID must not be empty")); + + // blank service-name + final IllegalArgumentException e4; + if (randomBoolean()) { + e4 = expectThrows(IllegalArgumentException.class, + () -> ServiceAccount.ServiceAccountId.fromPrincipal( + randomAlphaOfLengthBetween(3, 8) + "/" + randomFrom("", " ", "\t", " \t"))); + } else { + e4 = expectThrows(IllegalArgumentException.class, + () -> new ServiceAccount.ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomFrom("", " ", "\t", " \t", null))); + } + assertThat(e4.getMessage(), containsString("the service-name of a service account ID must not be empty")); + } + + public void testStreamReadWrite() throws IOException { + final ServiceAccount.ServiceAccountId accountId = + new ServiceAccount.ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + try (BytesStreamOutput out = new BytesStreamOutput()) { + accountId.write(out); + try (StreamInput in = out.bytes().streamInput()) { + assertThat(new ServiceAccount.ServiceAccountId(in), equalTo(accountId)); + } + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java new file mode 100644 index 0000000000000..faaf02aab7f61 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java @@ -0,0 +1,468 @@ +/* + * 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.security.authc.service; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.BoundTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.MockLogAppender; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.junit.Before; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ServiceAccountServiceTests extends ESTestCase { + + private ThreadContext threadContext; + private ServiceAccountsTokenStore serviceAccountsTokenStore; + private ServiceAccountService serviceAccountService; + private Transport transport; + + @Before + @SuppressForbidden(reason = "Allow accessing localhost") + public void init() throws UnknownHostException { + threadContext = new ThreadContext(Settings.EMPTY); + serviceAccountsTokenStore = mock(ServiceAccountsTokenStore.class); + final Settings.Builder builder = Settings.builder() + .put("xpack.security.enabled", true); + transport = mock(Transport.class); + final TransportAddress transportAddress; + if (randomBoolean()) { + transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + } else { + transportAddress = new TransportAddress(InetAddress.getLocalHost(), 9300); + } + if (randomBoolean()) { + builder.put("xpack.security.http.ssl.enabled", true); + } else { + builder.put("discovery.type", "single-node"); + } + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + serviceAccountService = new ServiceAccountService(serviceAccountsTokenStore, + new HttpTlsRuntimeCheck(builder.build(), new SetOnce<>(transport))); + } + + public void testIsServiceAccount() { + final User user = + new User(randomAlphaOfLengthBetween(3, 8), randomArray(0, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8))); + final Authentication.RealmRef authRealm; + final boolean authRealmIsForServiceAccount = randomBoolean(); + if (authRealmIsForServiceAccount) { + authRealm = new Authentication.RealmRef(ServiceAccountService.REALM_NAME, + ServiceAccountService.REALM_TYPE, + randomAlphaOfLengthBetween(3, 8)); + } else { + authRealm = randomRealmRef(); + } + final Authentication.RealmRef lookupRealm = randomFrom(randomRealmRef(), null); + final Authentication authentication = new Authentication(user, authRealm, lookupRealm); + + if (authRealmIsForServiceAccount && lookupRealm == null) { + assertThat(ServiceAccountService.isServiceAccount(authentication), is(true)); + } else { + assertThat(ServiceAccountService.isServiceAccount(authentication), is(false)); + } + } + + public void testGetServiceAccountPrincipals() { + assertThat(ServiceAccountService.getServiceAccountPrincipals(), + equalTo(org.elasticsearch.common.collect.Set.of("elastic/fleet-server"))); + } + + public void testTryParseToken() throws IOException, IllegalAccessException { + // Null for null + assertNull(ServiceAccountService.tryParseToken(null)); + + final byte[] magicBytes = { 0, 1, 0, 1 }; + + final Logger satLogger = LogManager.getLogger(ServiceAccountToken.class); + Loggers.setLevel(satLogger, Level.TRACE); + final Logger sasLogger = LogManager.getLogger(ServiceAccountService.class); + Loggers.setLevel(sasLogger, Level.TRACE); + + final MockLogAppender appender = new MockLogAppender(); + Loggers.addAppender(satLogger, appender); + Loggers.addAppender(sasLogger, appender); + appender.start(); + + try { + // Less than 4 bytes + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "less than 4 bytes", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes") + ); + final SecureString bearerString0 = createBearerString( + org.elasticsearch.common.collect.List.of(Arrays.copyOfRange(magicBytes, 0, randomIntBetween(0, 3)))); + assertNull(ServiceAccountService.tryParseToken(bearerString0)); + appender.assertAllExpectationsMatched(); + + // Prefix mismatch + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "prefix mismatch", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes" + )); + final SecureString bearerString1 = createBearerString(org.elasticsearch.common.collect.List.of( + new byte[] { randomValueOtherThan((byte) 0, ESTestCase::randomByte) }, + randomByteArrayOfLength(randomIntBetween(30, 50)))); + assertNull(ServiceAccountService.tryParseToken(bearerString1)); + appender.assertAllExpectationsMatched(); + + // No colon + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "no colon", ServiceAccountToken.class.getName(), Level.TRACE, + "failed to extract qualified service token name and secret, missing ':'" + )); + final SecureString bearerString2 = createBearerString(org.elasticsearch.common.collect.List.of( + magicBytes, + randomAlphaOfLengthBetween(30, 50).getBytes(StandardCharsets.UTF_8))); + assertNull(ServiceAccountService.tryParseToken(bearerString2)); + appender.assertAllExpectationsMatched(); + + // Invalid delimiter for qualified name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid delimiter for qualified name", ServiceAccountToken.class.getName(), Level.TRACE, + "The qualified name of a service token should take format of 'namespace/service_name/token_name'" + )); + if (randomBoolean()) { + final SecureString bearerString3 = createBearerString(org.elasticsearch.common.collect.List.of( + magicBytes, + (randomAlphaOfLengthBetween(10, 20) + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString3)); + } else { + final SecureString bearerString3 = createBearerString(org.elasticsearch.common.collect.List.of( + magicBytes, + (randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8) + + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString3)); + } + appender.assertAllExpectationsMatched(); + + // Invalid token name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid token name", ServiceAccountService.class.getName(), Level.TRACE, + "Cannot parse possible service account token" + )); + final SecureString bearerString4 = createBearerString(org.elasticsearch.common.collect.List.of( + magicBytes, + (randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8) + + "/" + randomValueOtherThanMany(n -> n.contains("/"), ServiceAccountTokenTests::randomInvalidTokenName) + + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString4)); + appender.assertAllExpectationsMatched(); + + // Everything is good + final String namespace = randomAlphaOfLengthBetween(3, 8); + final String serviceName = randomAlphaOfLengthBetween(3, 8); + final String tokenName = ServiceAccountTokenTests.randomTokenName(); + final ServiceAccountId accountId = new ServiceAccountId(namespace, serviceName); + final String secret = randomAlphaOfLengthBetween(10, 20); + final SecureString bearerString5 = createBearerString(org.elasticsearch.common.collect.List.of( + magicBytes, + (namespace + "/" + serviceName + "/" + tokenName + ":" + secret).getBytes(StandardCharsets.UTF_8) + )); + final ServiceAccountToken serviceAccountToken1 = ServiceAccountService.tryParseToken(bearerString5); + final ServiceAccountToken serviceAccountToken2 = new ServiceAccountToken(accountId, tokenName, + new SecureString(secret.toCharArray())); + assertThat(serviceAccountToken1, equalTo(serviceAccountToken2)); + + // Serialise and de-serialise service account token + final ServiceAccountToken parsedToken = ServiceAccountService.tryParseToken(serviceAccountToken2.asBearerString()); + assertThat(parsedToken, equalTo(serviceAccountToken2)); + + // Invalid magic byte + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid magic byte again", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AQEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // No colon + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "no colon again", ServiceAccountToken.class.getName(), Level.TRACE, + "failed to extract qualified service token name and secret, missing ':'" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xX3N1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // Invalid qualified name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid delimiter for qualified name again", ServiceAccountToken.class.getName(), Level.TRACE, + "The qualified name of a service token should take format of 'namespace/service_name/token_name'" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXRfdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // Invalid token name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid token name again", ServiceAccountService.class.getName(), Level.TRACE, + "Cannot parse possible service account token" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4hOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // everything is fine + assertThat(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA".toCharArray())), + equalTo(new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), "token1", + new SecureString("supersecret".toCharArray())))); + } finally { + appender.stop(); + Loggers.setLevel(satLogger, Level.INFO); + Loggers.setLevel(sasLogger, Level.INFO); + Loggers.removeAppender(satLogger, appender); + Loggers.removeAppender(sasLogger, appender); + } + } + + private Authentication.RealmRef randomRealmRef() { + return new Authentication.RealmRef(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8)); + } + + public void testTryAuthenticateBearerToken() throws ExecutionException, InterruptedException { + // Valid token + final PlainActionFuture future5 = new PlainActionFuture<>(); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(true); + return null; + }).when(serviceAccountsTokenStore).authenticate(any(), any()); + final String nodeName = randomAlphaOfLengthBetween(3, 8); + serviceAccountService.authenticateToken( + new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), "token1", + new SecureString("super-secret-value".toCharArray())), + nodeName, future5); + assertThat(future5.get(), equalTo( + new Authentication( + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, "Service account - elastic/fleet-server", null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), true), + new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", "token1") + ) + )); + } + + public void testAuthenticateWithToken() throws ExecutionException, InterruptedException, IllegalAccessException { + final Logger sasLogger = LogManager.getLogger(ServiceAccountService.class); + Loggers.setLevel(sasLogger, Level.TRACE); + + final MockLogAppender appender = new MockLogAppender(); + Loggers.addAppender(sasLogger, appender); + appender.start(); + + try { + // non-elastic service account + final ServiceAccountId accountId1 = new ServiceAccountId( + randomValueOtherThan(ElasticServiceAccounts.NAMESPACE, () -> randomAlphaOfLengthBetween(3, 8)), + randomAlphaOfLengthBetween(3, 8)); + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "only [elastic] service accounts are supported, but received [" + accountId1.asPrincipal() + "]" + )); + final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); + final ServiceAccountToken token1 = new ServiceAccountToken(accountId1, randomAlphaOfLengthBetween(3, 8), secret); + final PlainActionFuture future1 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token1, randomAlphaOfLengthBetween(3, 8), future1); + final ExecutionException e1 = expectThrows(ExecutionException.class, future1::get); + assertThat(e1.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e1.getMessage(), containsString("failed to authenticate service account [" + + token1.getAccountId().asPrincipal() + "] with token name [" + token1.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + + // Unknown elastic service name + final ServiceAccountId accountId2 = new ServiceAccountId( + ElasticServiceAccounts.NAMESPACE, + randomValueOtherThan("fleet-server", () -> randomAlphaOfLengthBetween(3, 8))); + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "the [" + accountId2.asPrincipal() + "] service account does not exist" + )); + final ServiceAccountToken token2 = new ServiceAccountToken(accountId2, randomAlphaOfLengthBetween(3, 8), secret); + final PlainActionFuture future2 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token2, randomAlphaOfLengthBetween(3, 8), future2); + final ExecutionException e2 = expectThrows(ExecutionException.class, future2::get); + assertThat(e2.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e2.getMessage(), containsString("failed to authenticate service account [" + + token2.getAccountId().asPrincipal() + "] with token name [" + token2.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + + // Success based on credential store + final ServiceAccountId accountId3 = new ServiceAccountId(ElasticServiceAccounts.NAMESPACE, "fleet-server"); + final ServiceAccountToken token3 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), secret); + final ServiceAccountToken token4 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), + new SecureString(randomAlphaOfLength(20).toCharArray())); + final String nodeName = randomAlphaOfLengthBetween(3, 8); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(true); + return null; + }).when(serviceAccountsTokenStore).authenticate(eq(token3), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(false); + return null; + }).when(serviceAccountsTokenStore).authenticate(eq(token4), any()); + + final PlainActionFuture future3 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token3, nodeName, future3); + final Authentication authentication = future3.get(); + assertThat(authentication, equalTo(new Authentication( + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, + "Service account - elastic/fleet-server", null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), + true), + new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", token3.getTokenName()) + ))); + + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "failed to authenticate service account [" + token4.getAccountId().asPrincipal() + + "] with token name [" + token4.getTokenName() + "]" + )); + final PlainActionFuture future4 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token4, nodeName, future4); + final ExecutionException e4 = expectThrows(ExecutionException.class, future4::get); + assertThat(e4.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e4.getMessage(), containsString("failed to authenticate service account [" + + token4.getAccountId().asPrincipal() + "] with token name [" + token4.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + } finally { + appender.stop(); + Loggers.setLevel(sasLogger, Level.INFO); + Loggers.removeAppender(sasLogger, appender); + } + } + + public void testGetRoleDescriptor() throws ExecutionException, InterruptedException { + final Authentication auth1 = new Authentication( + new User("elastic/fleet-server", + Strings.EMPTY_ARRAY, + "Service account - elastic/fleet-server", + null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), + true), + new Authentication.RealmRef( + ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, randomAlphaOfLengthBetween(3, 8)), + null, + Version.CURRENT, + Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", randomAlphaOfLengthBetween(3, 8))); + + final PlainActionFuture future1 = new PlainActionFuture<>(); + serviceAccountService.getRoleDescriptor(auth1, future1); + final RoleDescriptor roleDescriptor1 = future1.get(); + assertNotNull(roleDescriptor1); + assertThat(roleDescriptor1.getName(), equalTo("elastic/fleet-server")); + + final String username = + randomValueOtherThan("elastic/fleet-server", () -> randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8)); + final Authentication auth2 = new Authentication( + new User(username, Strings.EMPTY_ARRAY, "Service account - " + username, null, + org.elasticsearch.common.collect.Map.of("_elastic_service_account", true), true), + new Authentication.RealmRef( + ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, randomAlphaOfLengthBetween(3, 8)), + null, + Version.CURRENT, + Authentication.AuthenticationType.TOKEN, + org.elasticsearch.common.collect.Map.of("_token_name", randomAlphaOfLengthBetween(3, 8))); + final PlainActionFuture future2 = new PlainActionFuture<>(); + serviceAccountService.getRoleDescriptor(auth2, future2); + final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future2::actionGet); + assertThat(e.getMessage(), containsString( + "cannot load role for service account [" + username + "] - no such service account")); + } + + public void testTlsRequired() { + final Settings settings = Settings.builder() + .put("xpack.security.http.ssl.enabled", false) + .build(); + final TransportAddress transportAddress = new TransportAddress(TransportAddress.META_ADDRESS, 9300); + when(transport.boundAddress()).thenReturn( + new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); + + final ServiceAccountService service = new ServiceAccountService(serviceAccountsTokenStore, + new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); + + final PlainActionFuture future1 = new PlainActionFuture<>(); + service.authenticateToken(mock(ServiceAccountToken.class), randomAlphaOfLengthBetween(3, 8), future1); + final ElasticsearchException e1 = expectThrows(ElasticsearchException.class, future1::actionGet); + assertThat(e1.getMessage(), containsString("[service account authentication] requires TLS for the HTTP interface")); + + final PlainActionFuture future2 = new PlainActionFuture<>(); + final Authentication authentication = new Authentication(mock(User.class), + new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, + randomAlphaOfLengthBetween(3, 8)), + null); + service.getRoleDescriptor(authentication, future2); + final ElasticsearchException e2 = expectThrows(ElasticsearchException.class, future2::actionGet); + assertThat(e2.getMessage(), containsString("[service account role descriptor resolving] requires TLS for the HTTP interface")); + } + + private SecureString createBearerString(List bytesList) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (byte[] bytes : bytesList) { + out.write(bytes); + } + return new SecureString(Base64.getEncoder().withoutPadding().encodeToString(out.toByteArray()).toCharArray()); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java new file mode 100644 index 0000000000000..ea58b0ed52e88 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java @@ -0,0 +1,119 @@ +/* + * 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.security.authc.service; + +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class ServiceAccountTokenTests extends ESTestCase { + + private static final Set VALID_TOKEN_NAME_CHARS = org.elasticsearch.common.collect.Set.of( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', + 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '-', '_' + ); + + private static final Set INVALID_TOKEN_NAME_CHARS = org.elasticsearch.common.collect.Set.of( + '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ';', '<', '=', '>', '?', '@', '[', + '\\', ']', '^', '`', '{', '|', '}', '~', ' ', '\t', '\n', '\r'); + + public void testIsValidTokenName() { + final String tokenName1 = randomTokenName(); + assertThat(ServiceAccountToken.isValidTokenName(tokenName1), is(true)); + + final String tokenName2 = "_" + randomTokenName().substring(1); + assertThat(ServiceAccountToken.isValidTokenName(tokenName2), is(false)); + + assertThat(ServiceAccountToken.isValidTokenName(null), is(false)); + + final String tokenName3 = randomInvalidTokenName(); + assertThat(ServiceAccountToken.isValidTokenName(tokenName3), is(false)); + } + + public void testNewToken() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + ServiceAccountToken.newToken(accountId, randomTokenName()); + + final IllegalArgumentException e1 = + expectThrows(IllegalArgumentException.class, () -> ServiceAccountToken.newToken(accountId, randomInvalidTokenName())); + assertThat(e1.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); + + final NullPointerException e2 = + expectThrows(NullPointerException.class, () -> ServiceAccountToken.newToken(null, randomTokenName())); + assertThat(e2.getMessage(), containsString("service account ID cannot be null")); + } + + public void testServiceAccountTokenNew() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); + new ServiceAccountToken(accountId, randomTokenName(), secret); + + final NullPointerException e1 = + expectThrows(NullPointerException.class, () -> new ServiceAccountToken(null, randomTokenName(), secret)); + assertThat(e1.getMessage(), containsString("service account ID cannot be null")); + + final IllegalArgumentException e2 = + expectThrows(IllegalArgumentException.class, () -> new ServiceAccountToken(accountId, randomInvalidTokenName(), secret)); + assertThat(e2.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); + + final NullPointerException e3 = + expectThrows(NullPointerException.class, () -> new ServiceAccountToken(accountId, randomTokenName(), null)); + assertThat(e3.getMessage(), containsString("service account token secret cannot be null")); + } + + public void testBearerString() throws IOException { + final ServiceAccountToken serviceAccountToken = + new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), + "token1", new SecureString("supersecret".toCharArray())); + assertThat(serviceAccountToken.asBearerString(), equalTo("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA")); + + assertThat(ServiceAccountToken.fromBearerString( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA".toCharArray())), + equalTo(serviceAccountToken)); + + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final ServiceAccountToken serviceAccountToken1 = ServiceAccountToken.newToken(accountId, randomTokenName()); + assertThat(ServiceAccountToken.fromBearerString(serviceAccountToken1.asBearerString()), equalTo(serviceAccountToken1)); + } + + public static String randomTokenName() { + final Character[] chars = randomArray( + 1, + 256, + Character[]::new, + () -> randomFrom(VALID_TOKEN_NAME_CHARS)); + final String name = Arrays.stream(chars).map(String::valueOf).collect(Collectors.joining()); + return name.startsWith("_") ? randomAlphaOfLength(1) + name.substring(1) : name; + } + + public static String randomInvalidTokenName() { + if (randomBoolean()) { + final String tokenName = randomTokenName(); + final char[] chars = tokenName.toCharArray(); + IntStream.rangeClosed(1, randomIntBetween(1, chars.length)) + .forEach(i -> chars[randomIntBetween(0, chars.length - 1)] = randomFrom(INVALID_TOKEN_NAME_CHARS)); + return new String(chars); + } else { + return randomFrom("", " ", randomAlphaOfLength(257)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java index 8c4dfc223fc15..a6736e692a318 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java @@ -47,6 +47,7 @@ import org.elasticsearch.xpack.security.authc.AuthenticationService; import org.elasticsearch.xpack.security.authc.Realms; import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -67,6 +68,8 @@ import static org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -124,8 +127,15 @@ public void setupMocks() throws Exception { final ApiKeyService apiKeyService = new ApiKeyService(settings, clock, client, licenseState, securityIndex, clusterService, mock(CacheInvalidatorRegistry.class),threadPool); + final ServiceAccountService serviceAccountService = mock(ServiceAccountService.class); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(null); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); authenticationService = new AuthenticationService(settings, realms, auditTrail, failureHandler, threadPool, anonymous, - tokenService, apiKeyService, OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); + tokenService, apiKeyService, serviceAccountService, OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); authenticator = new SecondaryAuthenticator(securityContext, authenticationService); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java index 8100deba29bee..7193c79af38d7 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java @@ -69,6 +69,7 @@ import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -174,7 +175,8 @@ public void testRolesWhenDlsFlsUnlicensed() throws IOException { when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); final AtomicReference> effectiveRoleDescriptors = new AtomicReference>(); CompositeRolesStore compositeRolesStore = buildCompositeRolesStore(Settings.EMPTY, fileRolesStore, null, - null, null, licenseState, null, null, rds -> effectiveRoleDescriptors.set(rds)); + null, null, licenseState, null, null, + null, rds -> effectiveRoleDescriptors.set(rds)); PlainActionFuture roleFuture = new PlainActionFuture<>(); compositeRolesStore.roles(Collections.singleton("fls"), roleFuture); @@ -244,7 +246,8 @@ public void testRolesWhenDlsFlsLicensed() throws IOException { when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); final AtomicReference> effectiveRoleDescriptors = new AtomicReference>(); CompositeRolesStore compositeRolesStore = buildCompositeRolesStore(Settings.EMPTY, fileRolesStore, null, - null, null, licenseState, null, null, rds -> effectiveRoleDescriptors.set(rds)); + null, null, licenseState, null, null, + null, rds -> effectiveRoleDescriptors.set(rds)); PlainActionFuture roleFuture = new PlainActionFuture<>(); compositeRolesStore.roles(Collections.singleton("fls"), roleFuture); @@ -295,7 +298,7 @@ public void testNegativeLookupsAreCached() { final AtomicReference> effectiveRoleDescriptors = new AtomicReference>(); final CompositeRolesStore compositeRolesStore = buildCompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, nativePrivilegeStore, null, null, null, - rds -> effectiveRoleDescriptors.set(rds)); + null, rds -> effectiveRoleDescriptors.set(rds)); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor final String roleName = randomAlphaOfLengthBetween(1, 10); @@ -356,7 +359,8 @@ public void testNegativeLookupsCacheDisabled() { final DocumentSubsetBitsetCache documentSubsetBitsetCache = buildBitsetCache(); final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(settings), - new XPackLicenseState(settings, () -> 0), cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + new XPackLicenseState(settings, () -> 0), cache, mock(ApiKeyService.class), + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor @@ -395,7 +399,8 @@ public void testNegativeLookupsAreNotCachedWithFailures() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor @@ -483,7 +488,7 @@ public void testCustomRolesProviders() { new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider1, inMemoryProvider2), new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), - cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + cache, mock(ApiKeyService.class), mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); @@ -712,7 +717,8 @@ public void testCustomRolesProviderFailures() throws Exception { new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider1, failingProvider), new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), - cache, mock(ApiKeyService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); + cache, mock(ApiKeyService.class), mock(ServiceAccountService.class), + documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); PlainActionFuture future = new PlainActionFuture<>(); @@ -760,7 +766,8 @@ public void testCustomRolesProvidersLicensing() { CompositeRolesStore compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache, - mock(ApiKeyService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); + mock(ApiKeyService.class), mock(ServiceAccountService.class), + documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); Set roleNames = Sets.newHashSet("roleA"); PlainActionFuture future = new PlainActionFuture<>(); @@ -775,7 +782,8 @@ Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(Nativ compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache, - mock(ApiKeyService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); + mock(ApiKeyService.class), mock(ServiceAccountService.class), + documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); // these licenses allow custom role providers xPackLicenseState.update(randomFrom(OperationMode.PLATINUM, OperationMode.ENTERPRISE, OperationMode.TRIAL), true, Long.MAX_VALUE, null); @@ -793,7 +801,8 @@ Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(Nativ compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache, - mock(ApiKeyService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); + mock(ApiKeyService.class), mock(ServiceAccountService.class), + documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); xPackLicenseState.update(randomFrom(OperationMode.PLATINUM, OperationMode.ENTERPRISE, OperationMode.TRIAL), false, Long.MAX_VALUE, null); roleNames = Sets.newHashSet("roleA"); @@ -827,7 +836,8 @@ public void testCacheClearOnIndexHealthChange() { CompositeRolesStore compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(Settings.EMPTY), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> {}) { @Override public void invalidateAll() { @@ -882,7 +892,7 @@ public void testCacheClearOnIndexOutOfDateChange() { fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), - documentSubsetBitsetCache, rds -> {}) { + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> {}) { @Override public void invalidateAll() { numInvalidation.incrementAndGet(); @@ -911,7 +921,7 @@ public void testDefaultRoleUserWithoutRoles() { final CompositeRolesStore compositeRolesStore = buildCompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), null, mock(ApiKeyService.class), - null, null); + mock(ServiceAccountService.class), null, null); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor PlainActionFuture rolesFuture = new PlainActionFuture<>(); @@ -948,7 +958,8 @@ public void testAnonymousUserEnabledRoleAdded() { final ReservedRolesStore reservedRolesStore = spy(new ReservedRolesStore()); final CompositeRolesStore compositeRolesStore = buildCompositeRolesStore(settings, fileRolesStore, nativeRolesStore, - reservedRolesStore, mock(NativePrivilegeStore.class), null, mock(ApiKeyService.class), null, null); + reservedRolesStore, mock(NativePrivilegeStore.class), null, mock(ApiKeyService.class), + mock(ServiceAccountService.class), null, null); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor PlainActionFuture rolesFuture = new PlainActionFuture<>(); @@ -977,7 +988,8 @@ public void testDoesNotUseRolesStoreForXPacAndAsyncSearchUser() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor @@ -1018,7 +1030,8 @@ public void testGetRolesForSystemUserThrowsException() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, mock(ApiKeyService.class), + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor IllegalArgumentException iae = expectThrows(IllegalArgumentException.class, @@ -1058,7 +1071,8 @@ public void testApiKeyAuthUsesApiKeyService() throws Exception { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, nativePrivStore, Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, apiKeyService, documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, apiKeyService, + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); AuditUtil.getOrGenerateRequestId(threadContext); final Version version = randomFrom(Version.CURRENT, VersionUtils.randomVersionBetween(random(), Version.V_7_0_0, Version.V_7_8_1)); @@ -1113,7 +1127,8 @@ public void testApiKeyAuthUsesApiKeyServiceWithScopedRole() throws Exception { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, nativePrivStore, Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, apiKeyService, documentSubsetBitsetCache, + new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, apiKeyService, + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); AuditUtil.getOrGenerateRequestId(threadContext); final Version version = randomFrom(Version.CURRENT, VersionUtils.randomVersionBetween(random(), Version.V_7_0_0, Version.V_7_8_1)); @@ -1157,7 +1172,8 @@ public void testUsageStats() { final DocumentSubsetBitsetCache documentSubsetBitsetCache = buildBitsetCache(); final CompositeRolesStore compositeRolesStore = buildCompositeRolesStore( - SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, null, null, mock(ApiKeyService.class), + SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, null, null, + mock(ApiKeyService.class), mock(ServiceAccountService.class), documentSubsetBitsetCache, null); PlainActionFuture> usageStatsListener = new PlainActionFuture<>(); @@ -1202,7 +1218,8 @@ public void testLoggingOfDeprecatedRoles() { Collections.shuffle(descriptors, random()); final CompositeRolesStore compositeRolesStore = - buildCompositeRolesStore(SECURITY_ENABLED_SETTINGS, null, null, null, null, null, null, null, null); + buildCompositeRolesStore(SECURITY_ENABLED_SETTINGS, null, null, null, null, null, + null, mock(ServiceAccountService.class), null, null); // Use a LHS so that the random-shufle-order of the list is preserved compositeRolesStore.logDeprecatedRoles(new LinkedHashSet<>(descriptors)); @@ -1251,6 +1268,7 @@ public void testCacheEntryIsReusedForIdenticalApiKeyRoles() { new XPackLicenseState(SECURITY_ENABLED_SETTINGS, () -> 0), cache, apiKeyService, + mock(ServiceAccountService.class), documentSubsetBitsetCache, rds -> effectiveRoleDescriptors.set(rds)); AuditUtil.getOrGenerateRequestId(threadContext); @@ -1344,6 +1362,7 @@ private CompositeRolesStore buildCompositeRolesStore(Settings settings, @Nullable NativePrivilegeStore privilegeStore, @Nullable XPackLicenseState licenseState, @Nullable ApiKeyService apiKeyService, + @Nullable ServiceAccountService serviceAccountService, @Nullable DocumentSubsetBitsetCache documentSubsetBitsetCache, @Nullable Consumer> roleConsumer) { if (fileRolesStore == null) { @@ -1379,6 +1398,9 @@ private CompositeRolesStore buildCompositeRolesStore(Settings settings, if (apiKeyService == null) { apiKeyService = mock(ApiKeyService.class); } + if (serviceAccountService == null) { + serviceAccountService = mock(ServiceAccountService.class); + } if (documentSubsetBitsetCache == null) { documentSubsetBitsetCache = buildBitsetCache(); } @@ -1386,8 +1408,8 @@ private CompositeRolesStore buildCompositeRolesStore(Settings settings, roleConsumer = rds -> { }; } return new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, privilegeStore, - Collections.emptyList(), new ThreadContext(settings), licenseState, cache, apiKeyService, documentSubsetBitsetCache, - roleConsumer); + Collections.emptyList(), new ThreadContext(settings), licenseState, cache, apiKeyService, + serviceAccountService, documentSubsetBitsetCache, roleConsumer); } private DocumentSubsetBitsetCache buildBitsetCache() { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java new file mode 100644 index 0000000000000..8bb0fbc089140 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileLineParserTests.java @@ -0,0 +1,39 @@ +/* + * 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.security.support; + +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class FileLineParserTests extends ESTestCase { + + public void testParse() throws IOException { + Path path = getDataPath("../authc/support/role_mapping.yml"); + + final Map lines = new HashMap<>(org.elasticsearch.common.collect.Map.of( + 7, "security:", + 8, " - \"cn=avengers,ou=marvel,o=superheros\"", + 9, " - \"cn=shield,ou=marvel,o=superheros\"", + 10, "avenger:", + 11, " - \"cn=avengers,ou=marvel,o=superheros\"", + 12, " - \"cn=Horatio Hornblower,ou=people,o=sevenSeas\"" + )); + + FileLineParser.parse(path, (lineNumber, line) -> { + assertThat(lines.remove(lineNumber), equalTo(line)); + }); + assertThat(lines.isEmpty(), is(true)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java new file mode 100644 index 0000000000000..9acf561437225 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/FileReloadListenerTests.java @@ -0,0 +1,37 @@ +/* + * 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.security.support; + +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.test.ESTestCase; + +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +import static org.hamcrest.Matchers.equalTo; + +public class FileReloadListenerTests extends ESTestCase { + + public void testCallback() { + final CountDownLatch latch = new CountDownLatch(2); + final FileReloadListener fileReloadListener = new FileReloadListener(PathUtils.get("foo", "bar"), latch::countDown); + + Consumer consumer = + randomFrom(fileReloadListener::onFileCreated, fileReloadListener::onFileChanged, fileReloadListener::onFileDeleted); + + consumer.accept(PathUtils.get("foo", "bar")); + assertThat(latch.getCount(), equalTo(1L)); + + consumer.accept(PathUtils.get("fizz", "baz")); + assertThat(latch.getCount(), equalTo(1L)); + + consumer.accept(PathUtils.get("foo", "bar")); + assertThat(latch.getCount(), equalTo(0L)); + } +} diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens new file mode 100644 index 0000000000000..126c6f8621291 --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/authc/service/service_tokens @@ -0,0 +1,6 @@ +elastic/fleet-server/pbkdf2:{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo= +elastic/fleet-server/bcrypt10:$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe +elastic/fleet-server/pbkdf2_stretch:{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o= +elastic/fleet-server/pbkdf2_50000:{PBKDF2}50000$IMzlphNClmrP/du40yxGM3fNjklg8CuACds12+Ry0jM=$KEC1S9a0NOs3OJKM4gEeBboU18EP4+3m/pyIA4MBDGk= +elastic/fleet-server/bcrypt:$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i +elastic/fleet-server/plain:{plain}_By842iQQVKSCLxVcJZWvw diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml index 4274291bc9262..90842d3c04cd0 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml @@ -15,5 +15,5 @@ setup: # This is fragile - it needs to be updated every time we add a new cluster/index privilege # I would much prefer we could just check that specific entries are in the array, but we don't have # an assertion for that - - length: { "cluster" : 40 } + - length: { "cluster" : 41 } - length: { "index" : 19 } diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java new file mode 100644 index 0000000000000..55f7b0c7c589e --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java @@ -0,0 +1,194 @@ +/* + * 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.security.authc.service; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.io.PathUtilsForTesting; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.service.FileTokensTool.CreateFileTokenCommand; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests; +import static org.hamcrest.Matchers.containsString; + +public class FileTokensToolTests extends CommandTestCase { + + // the mock filesystem we use so permissions/users/groups can be modified + static FileSystem jimfs; + String pathHomeParameter; + + // the config dir for each test to use + Path confDir; + + // settings used to create an Environment for tools + Settings settings; + + Hasher hasher; + private final SecureString token1 = UUIDs.randomBase64UUIDSecureString(); + private final SecureString token2 = UUIDs.randomBase64UUIDSecureString(); + private final SecureString token3 = UUIDs.randomBase64UUIDSecureString(); + + @BeforeClass + public static void setupJimfs() throws IOException { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setupHome() throws IOException { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + hasher = getFastStoredHashAlgoForTests(); + + Files.write(confDir.resolve("service_tokens"), org.elasticsearch.common.collect.List.of( + "elastic/fleet-server/server_1:" + new String(hasher.hash(token1)), + "elastic/fleet-server/server_2:" + new String(hasher.hash(token2)), + "elastic/fleet-server/server_3:" + new String(hasher.hash(token3)) + )); + settings = Settings.builder() + .put("path.home", homeDir) + .put("xpack.security.authc.service_token_hashing.algorithm", hasher.name()) + .build(); + pathHomeParameter = "-Epath.home=" + homeDir; + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + @Override + protected Command newCommand() { + return new FileTokensTool() { + @Override + protected CreateFileTokenCommand newCreateFileTokenCommand() { + return new CreateFileTokenCommand() { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(FileTokensToolTests.this.settings, confDir); + } + }; + } + }; + } + + public void testParsePrincipalAndTokenName() throws UserException { + final String tokenName1 = randomAlphaOfLengthBetween(3, 8); + final Tuple tuple1 = + CreateFileTokenCommand.parsePrincipalAndTokenName( + org.elasticsearch.common.collect.List.of("elastic/fleet-server", tokenName1), Settings.EMPTY); + assertEquals("elastic/fleet-server", tuple1.v1()); + assertEquals(tokenName1, tuple1.v2()); + + final UserException e2 = expectThrows(UserException.class, + () -> CreateFileTokenCommand.parsePrincipalAndTokenName( + org.elasticsearch.common.collect.List.of(randomAlphaOfLengthBetween(6, 16)), Settings.EMPTY)); + assertThat(e2.getMessage(), containsString("Missing token-name argument")); + + final UserException e3 = expectThrows(UserException.class, + () -> CreateFileTokenCommand.parsePrincipalAndTokenName(org.elasticsearch.common.collect.List.of(), Settings.EMPTY)); + assertThat(e3.getMessage(), containsString("Missing service-account-principal and token-name arguments")); + + final UserException e4 = expectThrows(UserException.class, + () -> CreateFileTokenCommand.parsePrincipalAndTokenName( + org.elasticsearch.common.collect.List.of(randomAlphaOfLengthBetween(6, 16), + randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)), + Settings.EMPTY)); + assertThat(e4.getMessage(), containsString( + "Expected two arguments, service-account-principal and token-name, found extra:")); + } + + public void testCreateToken() throws Exception { + final String tokenName1 = randomValueOtherThanMany(n -> n.startsWith("-"), ServiceAccountTokenTests::randomTokenName); + execute("create", pathHomeParameter, "elastic/fleet-server", tokenName1); + assertServiceTokenExists("elastic/fleet-server/" + tokenName1); + final String tokenName2 = randomValueOtherThanMany(n -> n.startsWith("-") || n.equals(tokenName1), + ServiceAccountTokenTests::randomTokenName); + execute("create", pathHomeParameter, "elastic/fleet-server", tokenName2); + assertServiceTokenExists("elastic/fleet-server/" + tokenName2); + // token name with a leading hyphen requires an option terminator + final String tokenName3 = "-" + ServiceAccountTokenTests.randomTokenName().substring(1); + execute("create", pathHomeParameter, "elastic/fleet-server", "--", tokenName3); + assertServiceTokenExists("elastic/fleet-server/" + tokenName3); + final String output = terminal.getOutput(); + assertThat(output, containsString("SERVICE_TOKEN elastic/fleet-server/" + tokenName1 + " = ")); + assertThat(output, containsString("SERVICE_TOKEN elastic/fleet-server/" + tokenName2 + " = ")); + assertThat(output, containsString("SERVICE_TOKEN elastic/fleet-server/" + tokenName3 + " = ")); + } + + public void testCreateTokenWithInvalidTokenName() throws Exception { + final String tokenName = ServiceAccountTokenTests.randomInvalidTokenName(); + final String[] args = tokenName.startsWith("-") ? + new String[] { "create", pathHomeParameter, "elastic/fleet-server", "--", tokenName } : + new String[] { "create", pathHomeParameter, "elastic/fleet-server", tokenName }; + final UserException e = expectThrows(UserException.class, () -> execute(args)); + assertServiceTokenNotExists("elastic/fleet-server/" + tokenName); + assertThat(e.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); + } + + public void testCreateTokenWithInvalidServiceAccount() throws Exception { + final UserException e = expectThrows(UserException.class, + () -> execute("create", pathHomeParameter, + randomFrom("elastic/foo", "foo/fleet-server", randomAlphaOfLengthBetween(6, 16)), + randomAlphaOfLengthBetween(3, 8))); + assertThat(e.getMessage(), containsString("Unknown service account principal: ")); + assertThat(e.getMessage(), containsString("Must be one of ")); + } + + private void assertServiceTokenExists(String key) throws IOException { + List lines = Files.readAllLines(confDir.resolve("service_tokens"), StandardCharsets.UTF_8); + for (String line : lines) { + String[] keyHash = line.split(":", 2); + if (keyHash.length != 2) { + fail("Corrupted service_tokens file, line: " + line); + } + if (key.equals(keyHash[0])) { + return; + } + } + fail("Could not find key " + key + " in service_tokens file:\n" + lines.toString()); + } + + private void assertServiceTokenNotExists(String key) throws IOException { + List lines = Files.readAllLines(confDir.resolve("service_tokens"), StandardCharsets.UTF_8); + for (String line : lines) { + String[] keyHash = line.split(":", 2); + if (keyHash.length != 2) { + fail("Corrupted service_tokens file, line: " + line); + } + assertNotEquals(key, keyHash[0]); + } + } +}