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/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..30bbef58d3106 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponse.java @@ -0,0 +1,98 @@ +/* + * 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 static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toUnmodifiableList; + +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 ? List.of() : tokenInfos.stream().sorted().collect(toUnmodifiableList()); + } + + 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, toUnmodifiableList())); + 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, List.of())) { + info.toXContent(builder, params); + } + builder.endObject().field("file_tokens").startObject(); + for (TokenInfo info : tokenInfosBySource.getOrDefault(TokenInfo.TokenSource.FILE, 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..30e3e77b59d84 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/TokenInfo.java @@ -0,0 +1,90 @@ +/* + * 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.Map; +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, 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/authz/privilege/ClusterPrivilegeResolver.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java index 911104b94187d..829ad1b291539 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 @@ -57,6 +57,7 @@ public class ClusterPrivilegeResolver { private static final Set MANAGE_OIDC_PATTERN = Set.of("cluster:admin/xpack/security/oidc/*"); private static final Set MANAGE_TOKEN_PATTERN = Set.of("cluster:admin/xpack/security/token/*"); private static final Set MANAGE_API_KEY_PATTERN = Set.of("cluster:admin/xpack/security/api_key/*"); + private static final Set MANAGE_SERVICE_ACCOUNT_PATTERN = Set.of("cluster:admin/xpack/security/service_account/*"); private static final Set GRANT_API_KEY_PATTERN = Set.of(GrantApiKeyAction.NAME + "*"); private static final Set MONITOR_PATTERN = Set.of("cluster:monitor/*"); private static final Set MONITOR_ML_PATTERN = Set.of("cluster:monitor/xpack/ml/*"); @@ -124,6 +125,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", Set.of("cluster:admin" + "/ingest/pipeline/*")); @@ -176,6 +179,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/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..b75cdd99ce617 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountTokensResponseTests.java @@ -0,0 +1,127 @@ +/* + * 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.toUnmodifiableList()); + 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(), + tokenInfos.stream().collect(Collectors.toUnmodifiableList())); + } + } + + 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(), tokenInfos.stream().collect(Collectors.toUnmodifiableList())), + 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 4b5240a52db61..fe1cef9251bfe 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 @@ -191,6 +191,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 index a2875d6f0bfc9..d77ecb7095e72 100644 --- a/x-pack/plugin/security/qa/service-account/build.gradle +++ b/x-pack/plugin/security/qa/service-account/build.gradle @@ -40,5 +40,5 @@ testClusters.javaRestTest { 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", 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 index 767fa5b0a9fb6..bbf906fbc5057 100644 --- 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 @@ -33,18 +33,19 @@ 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 = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"; - private static final String INVALID_SERVICE_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOjNhSkNMYVFXUk4yc1hsT2R0eEEwU1E"; + 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\",\n" + + " \"username\": \"elastic/fleet-server\",\n" + " \"roles\": [],\n" - + " \"full_name\": \"Service account - elastic/fleet\",\n" + + " \"full_name\": \"Service account - elastic/fleet-server\",\n" + " \"email\": null,\n" + " \"metadata\": {\n" + " \"_elastic_service_account\": true\n" @@ -93,11 +94,22 @@ public void testAuthenticate() throws IOException { } 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)); - assertThat(e.getMessage(), containsString("failed to authenticate service account [elastic/fleet] with token name [token1]")); + 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 { @@ -126,16 +138,156 @@ public void testAuthenticateShouldWorkWithOAuthBearerToken() throws IOException public void testAuthenticateShouldDifferentiateBetweenNormalUserAndServiceAccount() throws IOException { final Request request = new Request("GET", "_security/_authenticate"); request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader( - "Authorization", basicAuthHeaderValue("elastic/fleet", new SecureString("x-pack-test-password".toCharArray())) + "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")); + assertThat(responseMap.get("username"), equalTo("elastic/fleet-server")); assertThat(responseMap.get("authentication_type"), equalTo("realm")); assertThat(responseMap.get("roles"), equalTo(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(Map.of())); + assertThat(getTokensResponseMap1.get("file_tokens"), equalTo(Map.of("token1", 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(Map.of("token1", Map.of()))); + assertThat(getTokensResponseMap2.get("tokens"), equalTo(Map.of( + "api-token-1", Map.of(), + "api-token-2", 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(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 index e3bc027f4e6d3..4595836b0ad6b 100644 --- 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 @@ -1 +1 @@ -elastic/fleet/token1:{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk= +elastic/fleet-server/token1:{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk= 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 index 22dd5ce5d5fca..cf50914800148 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -20,27 +21,46 @@ import java.util.Map; +import static org.elasticsearch.test.SecuritySettingsSource.addSSLSettingsForNodePEMFiles; import static org.hamcrest.Matchers.equalTo; public class ServiceAccountSingleNodeTests extends SecuritySingleNodeTestCase { - private static final String BEARER_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"; + 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/token1:" + + "elastic/fleet-server/token1:" + "{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk="; } public void testAuthenticateWithServiceFileToken() { - final AuthenticateRequest authenticateRequest = new AuthenticateRequest("elastic/fleet"); + 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", Strings.EMPTY_ARRAY, "Service account - elastic/fleet", null, + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, "Service account - elastic/fleet-server", null, Map.of("_elastic_service_account", true), true), new Authentication.RealmRef("service_account", "service_account", nodeName), null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, Map.of("_token_name", "token1") 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 2dca2473b82e3..1f43f0e2fd272 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,10 +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.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore; +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; @@ -256,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 +349,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()); @@ -492,10 +502,19 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste clusterService, cacheInvalidatorRegistry, threadPool); components.add(apiKeyService); - final ServiceAccountService serviceAccountService = new ServiceAccountService( - new CompositeServiceAccountsTokenStore( - List.of(new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool)), - threadPool.getThreadContext())); + 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( + 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, @@ -718,6 +737,9 @@ public static List> getSettings(List securityExten settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); settingsList.add(OPERATOR_PRIVILEGES_ENABLED); + settingsList.add(CachingServiceAccountsTokenStore.CACHE_TTL_SETTING); + settingsList.add(CachingServiceAccountsTokenStore.CACHE_HASH_ALGO_SETTING); + settingsList.add(CachingServiceAccountsTokenStore.CACHE_MAX_TOKENS_SETTING); // hide settings settingsList.add(Setting.listSetting(SecurityField.setting("hide_settings"), Collections.emptyList(), Function.identity(), @@ -848,6 +870,8 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class), new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class), new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class), + new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class), + new ActionHandler<>(GetServiceAccountTokensAction.INSTANCE, TransportGetServiceAccountTokensAction.class), usageAction, infoAction); } @@ -907,7 +931,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()) ); } @@ -1017,7 +1043,8 @@ public Map> getTransports(Settings settings, ThreadP return Map.of( // security based on Netty 4 SecurityField.NAME4, - () -> new SecurityNetty4ServerTransport( + () -> { + transportReference.set(new SecurityNetty4ServerTransport( settings, Version.CURRENT, threadPool, @@ -1027,10 +1054,13 @@ public Map> getTransports(Settings settings, ThreadP circuitBreakerService, ipFilter, getSslService(), - getNettySharedGroupFactory(settings)), + getNettySharedGroupFactory(settings))); + return transportReference.get(); + }, // security based on NIO SecurityField.NIO, - () -> new SecurityNioTransport(settings, + () -> { + transportReference.set(new SecurityNioTransport(settings, Version.CURRENT, threadPool, networkService, @@ -1040,6 +1070,8 @@ public Map> getTransports(Settings settings, ThreadP ipFilter, getSslService(), getNioGroupFactory(settings))); + return transportReference.get(); + }); } @Override 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 cbeed9dc562b0..eaa6c6716c5f3 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 @@ -716,6 +716,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/service/CachingServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java index 0d4cc958ec58a..cb6f97752f086 100644 --- 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 @@ -37,11 +37,13 @@ public abstract class CachingServiceAccountsTokenStore implements ServiceAccount 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) { @@ -122,6 +124,10 @@ public final void invalidateAll() { } } + protected Settings getSettings() { + return settings; + } + protected ThreadPool getThreadPool() { return threadPool; } 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..561de9083cdce --- /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(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(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 index b96265aac2824..587f63e03fffc 100644 --- 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 @@ -21,9 +21,9 @@ final class ElasticServiceAccounts { static final String NAMESPACE = "elastic"; - private static final ServiceAccount FLEET_ACCOUNT = new ElasticServiceAccount("fleet", + private static final ServiceAccount FLEET_ACCOUNT = new ElasticServiceAccount("fleet-server", new RoleDescriptor( - NAMESPACE + "/fleet", + NAMESPACE + "/fleet-server", new String[]{"monitor", "manage_own_api_key"}, new RoleDescriptor.IndicesPrivileges[]{ RoleDescriptor.IndicesPrivileges 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 index ffa56efb606ff..9f8a023b95c55 100644 --- 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 @@ -12,14 +12,17 @@ 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; @@ -27,12 +30,14 @@ 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 { @@ -69,6 +74,17 @@ public void doAuthenticate(ServiceAccountToken token, ActionListener li .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.toUnmodifiableList()); + listener.onResponse(tokenInfos); + } + public void addListener(Runnable listener) { refreshListeners.add(listener); } 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..86eafc534d059 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStore.java @@ -0,0 +1,195 @@ +/* + * 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.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, 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) + .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/ServiceAccountService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java index c5373fde6626a..2e3348b8d3e09 100644 --- 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 @@ -14,9 +14,12 @@ 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 java.util.Map; @@ -31,9 +34,11 @@ public class ServiceAccountService { private static final Logger logger = LogManager.getLogger(ServiceAccountService.class); private final ServiceAccountsTokenStore serviceAccountsTokenStore; + private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; - public ServiceAccountService(ServiceAccountsTokenStore serviceAccountsTokenStore) { + public ServiceAccountService(ServiceAccountsTokenStore serviceAccountsTokenStore, HttpTlsRuntimeCheck httpTlsRuntimeCheck) { this.serviceAccountsTokenStore = serviceAccountsTokenStore; + this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; } public static boolean isServiceAccount(Authentication authentication) { @@ -74,44 +79,53 @@ public static ServiceAccountToken tryParseToken(SecureString bearerString) { } } + 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()); - 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; - } + 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; + } - 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); + 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; } - }, listener::onFailure)); + + 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; - - 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()); + 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) { 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 index 4252cfed8abec..37590e545ed85 100644 --- 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 @@ -7,15 +7,11 @@ 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.List; -import java.util.function.Function; +import java.util.Collection; /** * The interface should be implemented by credential stores of different backends. @@ -27,36 +23,9 @@ public interface ServiceAccountsTokenStore { */ void authenticate(ServiceAccountToken token, ActionListener listener); - final class CompositeServiceAccountsTokenStore implements ServiceAccountsTokenStore { - - private static final Logger logger = LogManager.getLogger(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) { - 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); - } - } - } + /** + * 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 08bbddcb4874d..c0a7dfcfea8c1 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 @@ -230,42 +230,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) { @@ -285,6 +253,55 @@ public void getRoles(User user, Authentication authentication, ActionListener roleActionListener) { + serviceAccountService.getRoleDescriptor(authentication, ActionListener.wrap(roleDescriptor -> { + final RoleKey roleKey = new RoleKey(Set.of(roleDescriptor.getName()), "service_account"); + final Role existing = roleCache.get(roleKey); + if (existing == null) { + final long invalidationCounter = numInvalidation.get(); + buildThenMaybeCacheRole(roleKey, List.of(roleDescriptor), 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..e3461ba7ac781 --- /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 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..9a03df4640e2a --- /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 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/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/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 25e0e9e053735..202b64f9f3b78 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 @@ -1909,10 +1909,10 @@ public void testExpiredApiKey() { public void testCanAuthenticateServiceAccount() throws ExecutionException, InterruptedException { Mockito.reset(serviceAccountService); final Authentication authentication = new Authentication( - new User("elastic/fleet"), + new User("elastic/fleet-server"), new RealmRef("service_account", "service_account", "foo"), null); try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { - threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"); + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"); doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; @@ -1929,7 +1929,7 @@ public void testServiceAccountFailureWillNotFallthrough() { Mockito.reset(serviceAccountService); final RuntimeException bailOut = new RuntimeException("bail out"); try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { - threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"); + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpyNXdkYmRib1FTZTl2R09Ld2FKR0F3"); doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; 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 index ad2adea1cfcf5..ae225f2c92afa 100644 --- 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 @@ -16,10 +16,12 @@ 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.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; @@ -63,6 +65,11 @@ 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(); @@ -140,6 +147,11 @@ public void testCacheCanBeDisabled() throws ExecutionException, InterruptedExcep 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 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 index 789fe4d04e6e0..0a769b92f286d 100644 --- 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 @@ -12,11 +12,20 @@ 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.List; +import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +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; @@ -28,19 +37,24 @@ 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(List.of(store1, store2, store3), threadContext); } public void testAuthenticate() throws ExecutionException, InterruptedException { - final ServiceAccountToken token = mock(ServiceAccountToken.class); - - final ServiceAccountsTokenStore store1 = mock(ServiceAccountsTokenStore.class); - final ServiceAccountsTokenStore store2 = mock(ServiceAccountsTokenStore.class); - final ServiceAccountsTokenStore store3 = mock(ServiceAccountsTokenStore.class); + Mockito.reset(store1, store2, store3); + final ServiceAccountToken token = mock(ServiceAccountToken.class); final boolean store1Success = randomBoolean(); final boolean store2Success = randomBoolean(); final boolean store3Success = randomBoolean(); @@ -66,9 +80,6 @@ public void testAuthenticate() throws ExecutionException, InterruptedException { return null; }).when(store3).authenticate(eq(token), any()); - final ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore compositeStore = - new ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore(List.of(store1, store2, store3), threadContext); - final PlainActionFuture future = new PlainActionFuture<>(); compositeStore.authenticate(token, future); if (store1Success || store2Success || store3Success) { @@ -93,4 +104,78 @@ public void testAuthenticate() throws ExecutionException, InterruptedException { 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(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(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(List.of()); + return null; + }).when(store3).findTokensFor(any(), any()); + + final PlainActionFuture> future1 = new PlainActionFuture<>(); + compositeStore.findTokensFor(accountId1, future1); + final Collection result = future1.get(); + assertThat(result.stream().collect(Collectors.toUnmodifiableSet()), 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 index 60ab161643354..e897773f91594 100644 --- 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 @@ -31,7 +31,7 @@ public class ElasticServiceAccountsTests extends ESTestCase { public void testElasticFleetPrivileges() { - final Role role = Role.builder(ElasticServiceAccounts.ACCOUNTS.get("elastic/fleet").roleDescriptor(), null).build(); + 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)); @@ -42,7 +42,7 @@ public void testElasticFleetPrivileges() { 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 + // TODO: more tests when role descriptor is finalised for elastic/fleet-server } public void testElasticServiceAccount() { 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 index c7aed6940d745..f5a2c341cefa6 100644 --- 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 @@ -9,6 +9,7 @@ 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; @@ -17,8 +18,10 @@ 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; @@ -29,6 +32,7 @@ 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; @@ -39,6 +43,7 @@ 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 { @@ -78,19 +83,19 @@ public void testParseFile() throws Exception { assertThat(parsedTokenHashes, notNullValue()); assertThat(parsedTokenHashes.size(), is(5)); - assertThat(new String(parsedTokenHashes.get("elastic/fleet/bcrypt")), + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/bcrypt")), equalTo("$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i")); - assertThat(new String(parsedTokenHashes.get("elastic/fleet/bcrypt10")), + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/bcrypt10")), equalTo("$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe")); - assertThat(new String(parsedTokenHashes.get("elastic/fleet/pbkdf2")), + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/pbkdf2")), equalTo("{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo=")); - assertThat(new String(parsedTokenHashes.get("elastic/fleet/pbkdf2_50000")), + 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/pbkdf2_stretch")), + assertThat(new String(parsedTokenHashes.get("elastic/fleet-server/pbkdf2_stretch")), equalTo("{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o=")); - assertThat(parsedTokenHashes.get("elastic/fleet/plain"), nullValue()); + assertThat(parsedTokenHashes.get("elastic/fleet-server/plain"), nullValue()); } public void testParseFileNotExists() throws IllegalAccessException, IOException { @@ -117,7 +122,7 @@ public void testAutoReload() throws Exception { 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/" + tokenName; + final String qualifiedTokenName = "elastic/fleet-server/" + tokenName; assertThat(store.getTokenHashes().containsKey(qualifiedTokenName), is(true)); // A blank line should not trigger update @@ -135,30 +140,30 @@ public void testAutoReload() throws Exception { hasher.hash(new SecureString("46ToAwIHZWxhc3RpYwVmbGVldAZ0b2tlbjEWWkYtQ3dlWlVTZldJX3p5Vk9ySnlSQQAAAAAAAAA".toCharArray())); try (BufferedWriter writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { writer.newLine(); - writer.append("elastic/fleet/token1:").append(new String(newTokenHash)); + 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/token1"), is(true)); + 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/token1"), is(false)); + 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/tokenxfoobar"); + 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/tokenx:").append(new String(newTokenHash)); + 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()), @@ -181,4 +186,19 @@ public void testAutoReload() throws Exception { 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..fb69e5a699c1f --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountsTokenStoreTests.java @@ -0,0 +1,300 @@ +/* + * 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.Set; +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], Map.of(), 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.toUnmodifiableSet()), + equalTo(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 = Map.of("password", new String(CharArrays.toUtf8Bytes(hash), StandardCharsets.UTF_8)); + return new GetResponse(new GetResult( + 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), + Map.of(), 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), randomLong(), randomLong(), randomLong(), true + )) + }, randomLong()); + } +} 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 index b26ac4df092b8..e8b5faaf67403 100644 --- 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 @@ -10,25 +10,34 @@ 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; @@ -44,18 +53,38 @@ 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 - public void init() { + @SuppressForbidden(reason = "Allow accessing localhost") + public void init() throws UnknownHostException { threadContext = new ThreadContext(Settings.EMPTY); serviceAccountsTokenStore = mock(ServiceAccountsTokenStore.class); - serviceAccountService = new ServiceAccountService(serviceAccountsTokenStore); + 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() { @@ -81,7 +110,7 @@ public void testIsServiceAccount() { } public void testGetServiceAccountPrincipals() { - assertThat(ServiceAccountService.getServiceAccountPrincipals(), equalTo(Set.of("elastic/fleet"))); + assertThat(ServiceAccountService.getServiceAccountPrincipals(), equalTo(Set.of("elastic/fleet-server"))); } public void testTryParseToken() throws IOException, IllegalAccessException { @@ -224,8 +253,8 @@ public void testTryParseToken() throws IOException, IllegalAccessException { // everything is fine assertThat(ServiceAccountService.tryParseToken( - new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray())), - equalTo(new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), "token1", + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA".toCharArray())), + equalTo(new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), "token1", new SecureString("supersecret".toCharArray())))); } finally { appender.stop(); @@ -253,12 +282,12 @@ public void testTryAuthenticateBearerToken() throws ExecutionException, Interrup }).when(serviceAccountsTokenStore).authenticate(any(), any()); final String nodeName = randomAlphaOfLengthBetween(3, 8); serviceAccountService.authenticateToken( - new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), "token1", + 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", Strings.EMPTY_ARRAY, "Service account - elastic/fleet", null, + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, "Service account - elastic/fleet-server", null, Map.of("_elastic_service_account", true), true), new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, @@ -297,7 +326,7 @@ public void testAuthenticateWithToken() throws ExecutionException, InterruptedEx // Unknown elastic service name final ServiceAccountId accountId2 = new ServiceAccountId( ElasticServiceAccounts.NAMESPACE, - randomValueOtherThan("fleet", () -> randomAlphaOfLengthBetween(3, 8))); + 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" @@ -312,7 +341,7 @@ public void testAuthenticateWithToken() throws ExecutionException, InterruptedEx appender.assertAllExpectationsMatched(); // Success based on credential store - final ServiceAccountId accountId3 = new ServiceAccountId(ElasticServiceAccounts.NAMESPACE, "fleet"); + 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())); @@ -335,8 +364,8 @@ public void testAuthenticateWithToken() throws ExecutionException, InterruptedEx serviceAccountService.authenticateToken(token3, nodeName, future3); final Authentication authentication = future3.get(); assertThat(authentication, equalTo(new Authentication( - new User("elastic/fleet", Strings.EMPTY_ARRAY, - "Service account - elastic/fleet", null, Map.of("_elastic_service_account", true), + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, + "Service account - elastic/fleet-server", null, Map.of("_elastic_service_account", true), true), new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, @@ -364,9 +393,9 @@ public void testAuthenticateWithToken() throws ExecutionException, InterruptedEx public void testGetRoleDescriptor() throws ExecutionException, InterruptedException { final Authentication auth1 = new Authentication( - new User("elastic/fleet", + new User("elastic/fleet-server", Strings.EMPTY_ARRAY, - "Service account - elastic/fleet", + "Service account - elastic/fleet-server", null, Map.of("_elastic_service_account", true), true), @@ -381,10 +410,10 @@ ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, randomAlphaO serviceAccountService.getRoleDescriptor(auth1, future1); final RoleDescriptor roleDescriptor1 = future1.get(); assertNotNull(roleDescriptor1); - assertThat(roleDescriptor1.getName(), equalTo("elastic/fleet")); + assertThat(roleDescriptor1.getName(), equalTo("elastic/fleet-server")); final String username = - randomValueOtherThan("elastic/fleet", () -> randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8)); + 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, Map.of("_elastic_service_account", true), true), @@ -396,12 +425,37 @@ ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, randomAlphaO Map.of("_token_name", randomAlphaOfLengthBetween(3, 8))); final PlainActionFuture future2 = new PlainActionFuture<>(); serviceAccountService.getRoleDescriptor(auth2, future2); - final ExecutionException e = expectThrows(ExecutionException.class, () -> future2.get()); - assertThat(e.getCause().getClass(), is(ElasticsearchSecurityException.class)); + 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) { 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 index 2dbfd0f958352..f98b3ff9e0460 100644 --- 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 @@ -82,11 +82,12 @@ public void testServiceAccountTokenNew() { public void testBearerString() throws IOException { final ServiceAccountToken serviceAccountToken = - new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), + new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), "token1", new SecureString("supersecret".toCharArray())); - assertThat(serviceAccountToken.asBearerString(), equalTo("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0")); + assertThat(serviceAccountToken.asBearerString(), equalTo("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA")); - assertThat(ServiceAccountToken.fromBearerString(new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray())), + assertThat(ServiceAccountToken.fromBearerString( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQtc2VydmVyL3Rva2VuMTpzdXBlcnNlY3JldA".toCharArray())), equalTo(serviceAccountToken)); final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); 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 index 56b028dd4a0aa..126c6f8621291 100644 --- 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 @@ -1,6 +1,6 @@ -elastic/fleet/pbkdf2:{PBKDF2}10000$0N2h5/AsDS5uO0/A+B6y8AnTCJ3Tqo8nygbzu1gkgpo=$5aTcCtteHf2g2ye7Y3p6jSZBoGhNJ7l6F3tmUhPTwRo= -elastic/fleet/bcrypt10:$2a$10$ML0BUUxdzs8ApPNf1ayAwuh61ZhfqlzN/1DgZWZn6vNiUhpu1GKTe -elastic/fleet/pbkdf2_stretch:{PBKDF2_STRETCH}10000$Pa3oNkj8xTD8j2gTgjWnTvnE6jseKApWMFjcNCLxX1U=$84ECweHFZQ2DblHEjHTRWA+fG6h5bVMyTSJUmFvTo1o= -elastic/fleet/pbkdf2_50000:{PBKDF2}50000$IMzlphNClmrP/du40yxGM3fNjklg8CuACds12+Ry0jM=$KEC1S9a0NOs3OJKM4gEeBboU18EP4+3m/pyIA4MBDGk= -elastic/fleet/bcrypt:$2a$10$uuCzGHRrEz/QMB/.bmL8qOKXHhPNt57dYBbWCH/Hbb3SjUyZ.Hf1i -elastic/fleet/plain:{plain}_By842iQQVKSCLxVcJZWvw +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 index cc02ecfe7a9a1..7f8062696f85b 100644 --- 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 @@ -70,9 +70,9 @@ public void setupHome() throws IOException { hasher = getFastStoredHashAlgoForTests(); Files.write(confDir.resolve("service_tokens"), List.of( - "elastic/fleet/server_1:" + new String(hasher.hash(token1)), - "elastic/fleet/server_2:" + new String(hasher.hash(token2)), - "elastic/fleet/server_3:" + new String(hasher.hash(token3)) + "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) @@ -107,8 +107,8 @@ protected Environment createEnv(Map settings) throws UserExcepti public void testParsePrincipalAndTokenName() throws UserException { final String tokenName1 = randomAlphaOfLengthBetween(3, 8); final Tuple tuple1 = - CreateFileTokenCommand.parsePrincipalAndTokenName(List.of("elastic/fleet", tokenName1), Settings.EMPTY); - assertEquals("elastic/fleet", tuple1.v1()); + CreateFileTokenCommand.parsePrincipalAndTokenName(List.of("elastic/fleet-server", tokenName1), Settings.EMPTY); + assertEquals("elastic/fleet-server", tuple1.v1()); assertEquals(tokenName1, tuple1.v2()); final UserException e2 = expectThrows(UserException.class, @@ -129,36 +129,36 @@ public void testParsePrincipalAndTokenName() throws UserException { public void testCreateToken() throws Exception { final String tokenName1 = randomValueOtherThanMany(n -> n.startsWith("-"), ServiceAccountTokenTests::randomTokenName); - execute("create", pathHomeParameter, "elastic/fleet", tokenName1); - assertServiceTokenExists("elastic/fleet/" + tokenName1); + 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", tokenName2); - assertServiceTokenExists("elastic/fleet/" + tokenName2); + 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", "--", tokenName3); - assertServiceTokenExists("elastic/fleet/" + tokenName3); + execute("create", pathHomeParameter, "elastic/fleet-server", "--", tokenName3); + assertServiceTokenExists("elastic/fleet-server/" + tokenName3); final String output = terminal.getOutput(); - assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/" + tokenName1 + " = ")); - assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/" + tokenName2 + " = ")); - assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/" + tokenName3 + " = ")); + 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", "--", tokenName } : - new String[] { "create", pathHomeParameter, "elastic/fleet", tokenName }; + 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/" + tokenName); + 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", randomAlphaOfLengthBetween(6, 16)), + 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 "));