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 0000000000000..e79ddffd71981 Binary files /dev/null and b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 differ 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]); + } + } +}