diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponse.java index 4bfdea223d4a6..ea9daba79380b 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponse.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponse.java @@ -9,18 +9,17 @@ package org.elasticsearch.client.security; import org.elasticsearch.client.security.support.ServiceTokenInfo; -import org.elasticsearch.common.xcontent.ParseField; import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ParseField; import org.elasticsearch.common.xcontent.XContentParser; import java.io.IOException; +import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; -import java.util.stream.Stream; import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken; /** * Response when requesting credentials of a service account. @@ -28,65 +27,65 @@ public final class GetServiceAccountCredentialsResponse { private final String principal; - private final String nodeName; - private final List serviceTokenInfos; + private final List indexTokenInfos; + private final ServiceAccountCredentialsNodesResponse nodesResponse; - public GetServiceAccountCredentialsResponse( - String principal, String nodeName, List serviceTokenInfos) { + public GetServiceAccountCredentialsResponse(String principal, + List indexTokenInfos, + ServiceAccountCredentialsNodesResponse nodesResponse) { this.principal = Objects.requireNonNull(principal, "principal is required"); - this.nodeName = Objects.requireNonNull(nodeName, "nodeName is required"); - this.serviceTokenInfos = List.copyOf(Objects.requireNonNull(serviceTokenInfos, "service token infos are required)")); + this.indexTokenInfos = List.copyOf(Objects.requireNonNull(indexTokenInfos, "service token infos are required")); + this.nodesResponse = Objects.requireNonNull(nodesResponse, "nodes response is required"); } public String getPrincipal() { return principal; } - public String getNodeName() { - return nodeName; - } - - public List getServiceTokenInfos() { - return serviceTokenInfos; + public List getIndexTokenInfos() { + return indexTokenInfos; } - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - GetServiceAccountCredentialsResponse that = (GetServiceAccountCredentialsResponse) o; - return principal.equals(that.principal) && nodeName.equals(that.nodeName) && serviceTokenInfos.equals(that.serviceTokenInfos); - } - - @Override - public int hashCode() { - return Objects.hash(principal, nodeName, serviceTokenInfos); + public ServiceAccountCredentialsNodesResponse getNodesResponse() { + return nodesResponse; } + @SuppressWarnings("unchecked") static ConstructingObjectParser PARSER = new ConstructingObjectParser<>("get_service_account_credentials_response", args -> { - @SuppressWarnings("unchecked") - final List tokenInfos = Stream.concat( - ((Map) args[3]).keySet().stream().map(name -> new ServiceTokenInfo(name, "index")), - ((Map) args[4]).keySet().stream().map(name -> new ServiceTokenInfo(name, "file"))) - .collect(Collectors.toList()); - assert tokenInfos.size() == (int) args[2] : "number of tokens do not match"; - return new GetServiceAccountCredentialsResponse((String) args[0], (String) args[1], tokenInfos); + final int count = (int) args[1]; + final List indexTokenInfos = (List) args[2]; + final ServiceAccountCredentialsNodesResponse fileTokensResponse = (ServiceAccountCredentialsNodesResponse) args[3]; + if (count != indexTokenInfos.size() + fileTokensResponse.getFileTokenInfos().size()) { + throw new IllegalArgumentException("number of tokens do not match"); + } + return new GetServiceAccountCredentialsResponse((String) args[0], indexTokenInfos, fileTokensResponse); }); static { PARSER.declareString(constructorArg(), new ParseField("service_account")); - PARSER.declareString(constructorArg(), new ParseField("node_name")); PARSER.declareInt(constructorArg(), new ParseField("count")); - PARSER.declareObject(constructorArg(), (p, c) -> p.map(), new ParseField("tokens")); - PARSER.declareObject(constructorArg(), (p, c) -> p.map(), new ParseField("file_tokens")); + PARSER.declareObject(constructorArg(), + (p, c) -> GetServiceAccountCredentialsResponse.parseIndexTokenInfos(p), new ParseField("tokens")); + PARSER.declareObject(constructorArg(), + (p, c) -> ServiceAccountCredentialsNodesResponse.fromXContent(p), new ParseField("nodes_credentials")); } public static GetServiceAccountCredentialsResponse fromXContent(XContentParser parser) throws IOException { return PARSER.parse(parser, null); } + static List parseIndexTokenInfos(XContentParser parser) throws IOException { + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); + final List indexTokenInfos = new ArrayList<>(); + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + ensureExpectedToken(XContentParser.Token.FIELD_NAME, token, parser); + indexTokenInfos.add(new ServiceTokenInfo(parser.currentName(), "index")); + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser); + ensureExpectedToken(XContentParser.Token.END_OBJECT, parser.nextToken(), parser); + } + return indexTokenInfos; + } } diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ServiceAccountCredentialsNodesResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ServiceAccountCredentialsNodesResponse.java new file mode 100644 index 0000000000000..8fb268d96e5ff --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ServiceAccountCredentialsNodesResponse.java @@ -0,0 +1,81 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.NodesResponseHeader; +import org.elasticsearch.client.security.support.ServiceTokenInfo; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentParserUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken; +import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName; + +public class ServiceAccountCredentialsNodesResponse { + + private final NodesResponseHeader header; + private final List fileTokenInfos; + + public ServiceAccountCredentialsNodesResponse( + NodesResponseHeader header, List fileTokenInfos) { + this.header = header; + this.fileTokenInfos = fileTokenInfos; + } + + public NodesResponseHeader getHeader() { + return header; + } + + public List getFileTokenInfos() { + return fileTokenInfos; + } + + public static ServiceAccountCredentialsNodesResponse fromXContent(XContentParser parser) throws IOException { + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); + NodesResponseHeader header = null; + List fileTokenInfos = List.of(); + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + ensureExpectedToken(XContentParser.Token.FIELD_NAME, token, parser); + if ("_nodes".equals(parser.currentName())) { + if (header == null) { + header = NodesResponseHeader.fromXContent(parser, null); + } else { + throw new IllegalArgumentException("expecting only a single [_nodes] field, multiple found"); + } + } else if ("file_tokens".equals(parser.currentName())) { + fileTokenInfos = parseFileToken(parser); + } else { + throw new IllegalArgumentException("expecting field of either [_nodes] or [file_tokens], found [" + + parser.currentName() + "]"); + } + } + return new ServiceAccountCredentialsNodesResponse(header, fileTokenInfos); + } + + static List parseFileToken(XContentParser parser) throws IOException { + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser); + XContentParser.Token token; + final ArrayList fileTokenInfos = new ArrayList<>(); + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + ensureExpectedToken(XContentParser.Token.FIELD_NAME, token, parser); + final String tokenName = parser.currentName(); + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser); + ensureFieldName(parser, parser.nextToken(), "nodes"); + parser.nextToken(); + final List nodeNames = XContentParserUtils.parseList(parser, XContentParser::text); + ensureExpectedToken(XContentParser.Token.END_OBJECT, parser.nextToken(), parser); + fileTokenInfos.add(new ServiceTokenInfo(tokenName, "file", nodeNames)); + } + return fileTokenInfos; + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ServiceTokenInfo.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ServiceTokenInfo.java index 7a60ad573f661..c00aea1de6475 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ServiceTokenInfo.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ServiceTokenInfo.java @@ -8,15 +8,25 @@ package org.elasticsearch.client.security.support; +import org.elasticsearch.core.Nullable; + +import java.util.Collection; import java.util.Objects; public class ServiceTokenInfo { private final String name; private final String source; + @Nullable + private final Collection nodeNames; public ServiceTokenInfo(String name, String source) { + this(name, source, null); + } + + public ServiceTokenInfo(String name, String source, Collection nodeNames) { this.name = Objects.requireNonNull(name, "token name is required"); this.source = Objects.requireNonNull(source, "token source is required"); + this.nodeNames = nodeNames; } public String getName() { @@ -27,6 +37,10 @@ public String getSource() { return source; } + public Collection getNodeNames() { + return nodeNames; + } + @Override public boolean equals(Object o) { if (this == o) @@ -34,16 +48,16 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; ServiceTokenInfo that = (ServiceTokenInfo) o; - return name.equals(that.name) && source.equals(that.source); + return Objects.equals(name, that.name) && Objects.equals(source, that.source) && Objects.equals(nodeNames, that.nodeNames); } @Override public int hashCode() { - return Objects.hash(name, source); + return Objects.hash(name, source, nodeNames); } @Override public String toString() { - return "ServiceTokenInfo{" + "name='" + name + '\'' + ", source='" + source + '\'' + '}'; + return "ServiceTokenInfo{" + "name='" + name + '\'' + ", source='" + source + '\'' + ", nodeNames=" + nodeNames + '}'; } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index 6ad5cadf0dce5..3be558587db5a 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -14,6 +14,7 @@ import org.elasticsearch.action.LatchedActionListener; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.client.ESRestHighLevelClientTestCase; +import org.elasticsearch.client.NodesResponseHeader; import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RestHighLevelClient; import org.elasticsearch.client.security.AuthenticateResponse; @@ -122,6 +123,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -2745,17 +2747,23 @@ public void testGetServiceAccountCredentials() throws IOException { // tag::get-service-account-credentials-response final String principal = getServiceAccountCredentialsResponse.getPrincipal(); // <1> - final String nodeName = getServiceAccountCredentialsResponse.getNodeName(); // <2> - final List serviceTokenInfos = getServiceAccountCredentialsResponse.getServiceTokenInfos(); // <3> - final String tokenName = serviceTokenInfos.get(0).getName(); // <4> - final String tokenSource = serviceTokenInfos.get(0).getSource(); // <5> + final List indexTokenInfos = getServiceAccountCredentialsResponse.getIndexTokenInfos(); // <2> + final String tokenName = indexTokenInfos.get(0).getName(); // <3> + final String tokenSource = indexTokenInfos.get(0).getSource(); // <4> + final Collection nodeNames = indexTokenInfos.get(0).getNodeNames(); // <5> + final List fileTokenInfos + = getServiceAccountCredentialsResponse.getNodesResponse().getFileTokenInfos(); // <6> + final NodesResponseHeader fileTokensResponseHeader + = getServiceAccountCredentialsResponse.getNodesResponse().getHeader(); // <7> + final int nSuccessful = fileTokensResponseHeader.getSuccessful(); // <8> + final int nFailed = fileTokensResponseHeader.getFailed(); // <9> // end::get-service-account-credentials-response assertThat(principal, equalTo("elastic/fleet-server")); // Cannot assert exactly one token because there are rare occasions where tests overlap and it will see // token created from other tests - assertThat(serviceTokenInfos.size(), greaterThanOrEqualTo(1)); - assertThat(serviceTokenInfos.stream().map(ServiceTokenInfo::getName).collect(Collectors.toSet()), hasItem("token2")); - assertThat(serviceTokenInfos.stream().map(ServiceTokenInfo::getSource).collect(Collectors.toSet()), hasItem("index")); + assertThat(indexTokenInfos.size(), greaterThanOrEqualTo(1)); + assertThat(indexTokenInfos.stream().map(ServiceTokenInfo::getName).collect(Collectors.toSet()), hasItem("token2")); + assertThat(indexTokenInfos.stream().map(ServiceTokenInfo::getSource).collect(Collectors.toSet()), hasItem("index")); } { @@ -2787,8 +2795,8 @@ public void onFailure(Exception e) { assertNotNull(future.actionGet()); assertThat(future.actionGet().getPrincipal(), equalTo("elastic/fleet-server")); - assertThat(future.actionGet().getServiceTokenInfos().size(), greaterThanOrEqualTo(1)); - assertThat(future.actionGet().getServiceTokenInfos().stream().map(ServiceTokenInfo::getName).collect(Collectors.toSet()), + assertThat(future.actionGet().getIndexTokenInfos().size(), greaterThanOrEqualTo(1)); + assertThat(future.actionGet().getIndexTokenInfos().stream().map(ServiceTokenInfo::getName).collect(Collectors.toSet()), hasItem("token2")); } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponseTests.java index 82a4ecc6796de..5eafe7c04eed2 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponseTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetServiceAccountCredentialsResponseTests.java @@ -8,15 +8,23 @@ package org.elasticsearch.client.security; +import org.elasticsearch.Version; +import org.elasticsearch.action.FailedNodeException; import org.elasticsearch.client.AbstractResponseTestCase; +import org.elasticsearch.cluster.ClusterName; +import org.elasticsearch.cluster.node.DiscoveryNode; +import org.elasticsearch.common.transport.TransportAddress; import org.elasticsearch.core.Tuple; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesResponse; import org.elasticsearch.xpack.core.security.action.service.TokenInfo; import java.io.IOException; +import java.util.List; import java.util.Locale; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.hamcrest.Matchers.equalTo; @@ -27,15 +35,17 @@ public class GetServiceAccountCredentialsResponseTests @Override protected org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse createServerTestInstance( XContentType xContentType) { + final String[] fileTokenNames = randomArray(3, 5, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)); + final GetServiceAccountCredentialsNodesResponse nodesResponse = new GetServiceAccountCredentialsNodesResponse( + new ClusterName(randomAlphaOfLength(12)), + List.of(new GetServiceAccountCredentialsNodesResponse.Node(new DiscoveryNode(randomAlphaOfLength(10), + new TransportAddress(TransportAddress.META_ADDRESS, 9300), + Version.CURRENT), fileTokenNames)), + List.of(new FailedNodeException(randomAlphaOfLength(11), "error", new NoSuchFieldError("service_tokens")))); return new org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse( randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8), - randomAlphaOfLengthBetween(3, 8), randomList( - 1, - 5, - () -> randomBoolean() ? - TokenInfo.fileToken(randomAlphaOfLengthBetween(3, 8)) : - TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8))) - ); + randomList(0, 5, () -> TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8))), + nodesResponse); } @Override @@ -48,14 +58,19 @@ protected void assertInstances( org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse serverTestInstance, GetServiceAccountCredentialsResponse clientInstance) { assertThat(serverTestInstance.getPrincipal(), equalTo(clientInstance.getPrincipal())); - assertThat(serverTestInstance.getNodeName(), equalTo(clientInstance.getNodeName())); assertThat( - serverTestInstance.getTokenInfos().stream() + Stream.concat(serverTestInstance.getIndexTokenInfos().stream(), + serverTestInstance.getNodesResponse().getFileTokenInfos().stream()) .map(tokenInfo -> new Tuple<>(tokenInfo.getName(), tokenInfo.getSource().name().toLowerCase(Locale.ROOT))) .collect(Collectors.toSet()), - equalTo(clientInstance.getServiceTokenInfos().stream() + equalTo(Stream.concat(clientInstance.getIndexTokenInfos().stream(), + clientInstance.getNodesResponse().getFileTokenInfos().stream()) .map(info -> new Tuple<>(info.getName(), info.getSource())) .collect(Collectors.toSet()))); + + assertThat( + serverTestInstance.getNodesResponse().failures().size(), + equalTo(clientInstance.getNodesResponse().getHeader().getFailures().size())); } } diff --git a/docs/java-rest/high-level/security/get-service-account-credentials.asciidoc b/docs/java-rest/high-level/security/get-service-account-credentials.asciidoc index 7611a9a689438..5404c7869e159 100644 --- a/docs/java-rest/high-level/security/get-service-account-credentials.asciidoc +++ b/docs/java-rest/high-level/security/get-service-account-credentials.asciidoc @@ -24,15 +24,19 @@ include::../execution.asciidoc[] [id="{upid}-{api}-response"] ==== Get Service Account Credentials Response -The returned +{response}+ contains a list of service account tokens for the requested service account. +The returned +{response}+ contains service tokens for the requested service account. ["source","java",subs="attributes,callouts,macros"] -------------------------------------------------- include-tagged::{doc-tests-file}[{api}-response] -------------------------------------------------- <1> Principal of the service account -<2> Name of the node that processed the request. Information of file service tokens is only collected from this node. -<3> List of service token information -<4> Name of the first service account token -<5> Source of the first service account token. The value is either `file` or `index`. +<2> List of index-based service token information +<3> Name of the first service token +<4> Source of the first service token. The value is either `file` or `index`. +<5> For `file` service tokens, names of the nodes where the information is collected. +<6> List of file-based service token information +<7> Response header containing the information about the execution of collecting `file` service tokens. +<8> Number of nodes that successful complete the request of retrieving file-backed service tokens +<9> Number of nodes that fail to complete the request of retrieving file-backed service tokens diff --git a/x-pack/docs/en/rest-api/security/get-service-credentials.asciidoc b/x-pack/docs/en/rest-api/security/get-service-credentials.asciidoc index 9ce35d380c9a6..01b86488d8975 100644 --- a/x-pack/docs/en/rest-api/security/get-service-credentials.asciidoc +++ b/x-pack/docs/en/rest-api/security/get-service-credentials.asciidoc @@ -2,8 +2,6 @@ [[security-api-get-service-credentials]] === Get service account credentials API -beta::[] - ++++ Get service account credentials ++++ @@ -25,13 +23,15 @@ Retrieves all service credentials for a <>. ==== {api-description-title} include::../../security/authentication/service-accounts.asciidoc[tag=service-accounts-tls] -Use this API to retrieve a list of credentials for a service account. +Use this API to retrieve a list of credentials for a service account. The response includes service account tokens that were created with the -<< create service account API >> as well as file-backed tokens that -are local to the node. +<< create service account API >> as well as file-backed tokens from all +nodes of the cluster. -NOTE: For tokens backed by the `service_tokens` file, the API only returns -tokens defined in the file local to the node against which the request was issued. +NOTE: For tokens backed by the `service_tokens` file, the API collects +them from all nodes of the cluster. Tokens with the same name from +different nodes are assumed to be the same token and are only counted once +towards the total number of service tokens. [[security-api-get-service-credentials-path-params]] ==== {api-path-parms-title} @@ -67,19 +67,29 @@ The response includes all credentials related to the specified service account: ---- { "service_account": "elastic/fleet-server", - "node_name": "node0", <1> "count": 3, "tokens": { - "token1": {}, <2> - "token42": {} <3> + "token1": {}, <1> + "token42": {} <2> }, - "file_tokens": { - "my-token": {} <4> + "nodes_credentials": { <3> + "_nodes": { <4> + "total": 3, + "successful": 3, + "failed": 0 + }, + "file_tokens": { <5> + "my-token": { + "nodes": [ "node0", "node1" ] <6> + } + } } } ---- // NOTCONSOLE -<1> The local node name -<2> A new service account token backed by the `.security` index -<3> An existing service account token backed by the `.security` index -<4> A file-backed token local to the `node0` node +<1> A new service account token backed by the `.security` index +<2> An existing service account token backed by the `.security` index +<3> This section contains service account credentials collected from all nodes of the cluster +<4> General status showing how nodes respond to the above collection request +<5> File-backed tokens collected from all nodes +<6> List of nodes that (file-backed) `my-token` is found diff --git a/x-pack/plugin/build.gradle b/x-pack/plugin/build.gradle index 04862bd4d55a6..93c6bcba6cf97 100644 --- a/x-pack/plugin/build.gradle +++ b/x-pack/plugin/build.gradle @@ -143,6 +143,7 @@ def v7compatibilityNotSupportedTests = { 'rollup/put_job/Test basic put_job', //https://github.com/elastic/elasticsearch/pull/41502 'rollup/start_job/Test start job twice', + 'service_accounts/10_basic/Test service account tokens', // https://github.com/elastic/elasticsearch/pull/75200 // a type field was added to cat.ml_trained_models #73660, this is a backwards compatible change. // still this is a cat api, and we don't support them with rest api compatibility. (the test would be very hard to transform too) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesRequest.java new file mode 100644 index 0000000000000..89ff31e3fde81 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesRequest.java @@ -0,0 +1,76 @@ +/* + * 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.support.nodes.BaseNodesRequest; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.transport.TransportRequest; + +import java.io.IOException; + +/** + * Request for retrieving service account credentials that are local to each node. + * Currently, this means file-backed service tokens. + */ +public class GetServiceAccountCredentialsNodesRequest extends BaseNodesRequest { + + private final String namespace; + private final String serviceName; + + public GetServiceAccountCredentialsNodesRequest(String namespace, String serviceName) { + super((String[]) null); + this.namespace = namespace; + this.serviceName = serviceName; + } + + public GetServiceAccountCredentialsNodesRequest(StreamInput in) throws IOException { + super(in); + this.namespace = in.readString(); + this.serviceName = in.readString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(namespace); + out.writeString(serviceName); + } + + public static class Node extends TransportRequest { + + private final String namespace; + private final String serviceName; + + public Node(GetServiceAccountCredentialsNodesRequest request) { + this.namespace = request.namespace; + this.serviceName = request.serviceName; + } + + public Node(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 void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(namespace); + out.writeString(serviceName); + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesResponse.java new file mode 100644 index 0000000000000..ba731caec1ec7 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsNodesResponse.java @@ -0,0 +1,91 @@ +/* + * 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.FailedNodeException; +import org.elasticsearch.action.support.nodes.BaseNodeResponse; +import org.elasticsearch.action.support.nodes.BaseNodesResponse; +import org.elasticsearch.cluster.ClusterName; +import org.elasticsearch.cluster.node.DiscoveryNode; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Unlike index-backed service account tokens, file-backed tokens are local to the node. + * This response is to fetch information about them from each node. Note the class is + * more generically named for the possible future expansion to cover other types of credentials + * that are local to the node. + */ +public class GetServiceAccountCredentialsNodesResponse extends BaseNodesResponse { + + public GetServiceAccountCredentialsNodesResponse(ClusterName clusterName, + List nodes, + List failures) { + super(clusterName, nodes, failures); + } + + public GetServiceAccountCredentialsNodesResponse(StreamInput in) throws IOException { + super(in); + } + + @Override + protected List readNodesFrom(StreamInput in) throws IOException { + return in.readList(GetServiceAccountCredentialsNodesResponse.Node::new); + } + + @Override + protected void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + public List getFileTokenInfos() { + final Map> fileTokenDistribution = new HashMap<>(); + for (GetServiceAccountCredentialsNodesResponse.Node node: getNodes()) { + if (node.fileTokenNames == null) { + continue; + } + Arrays.stream(node.fileTokenNames).forEach(name -> { + final Set distribution = fileTokenDistribution.computeIfAbsent(name, k -> new HashSet<>()); + distribution.add(node.getNode().getName()); + }); + } + return fileTokenDistribution.entrySet().stream() + .map(entry -> TokenInfo.fileToken(entry.getKey(), entry.getValue().stream().sorted().collect(Collectors.toUnmodifiableList()))) + .collect(Collectors.toUnmodifiableList()); + } + + public static class Node extends BaseNodeResponse { + + public final String[] fileTokenNames; + + public Node(StreamInput in) throws IOException { + super(in); + this.fileTokenNames = in.readStringArray(); + } + + public Node(DiscoveryNode node, String[] fileTokenNames) { + super(node); + this.fileTokenNames = fileTokenNames; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeStringArray(fileTokenNames); + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponse.java index 4140cd2cfc967..8ad5ea38048a8 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponse.java @@ -12,87 +12,79 @@ import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.rest.action.RestActions; 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 GetServiceAccountCredentialsResponse extends ActionResponse implements ToXContentObject { private final String principal; - private final String nodeName; - private final List tokenInfos; + private final List indexTokenInfos; + private final GetServiceAccountCredentialsNodesResponse nodesResponse; - public GetServiceAccountCredentialsResponse(String principal, String nodeName, Collection tokenInfos) { + public GetServiceAccountCredentialsResponse(String principal, Collection indexTokenInfos, + GetServiceAccountCredentialsNodesResponse nodesResponse) { this.principal = principal; - this.nodeName = nodeName; - this.tokenInfos = tokenInfos == null ? List.of() : tokenInfos.stream().sorted().collect(toUnmodifiableList()); + this.indexTokenInfos = indexTokenInfos == null ? List.of() : indexTokenInfos.stream().sorted().collect(toUnmodifiableList()); + this.nodesResponse = nodesResponse; } public GetServiceAccountCredentialsResponse(StreamInput in) throws IOException { super(in); this.principal = in.readString(); - this.nodeName = in.readString(); - this.tokenInfos = in.readList(TokenInfo::new); + this.indexTokenInfos = in.readList(TokenInfo::new); + this.nodesResponse = new GetServiceAccountCredentialsNodesResponse(in); } public String getPrincipal() { return principal; } - public String getNodeName() { - return nodeName; + public List getIndexTokenInfos() { + return indexTokenInfos; } - public Collection getTokenInfos() { - return tokenInfos; + public GetServiceAccountCredentialsNodesResponse getNodesResponse() { + return nodesResponse; } @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(principal); - out.writeString(nodeName); - out.writeList(tokenInfos); + out.writeList(indexTokenInfos); + nodesResponse.writeTo(out); } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - final Map> tokenInfosBySource = - tokenInfos.stream().collect(groupingBy(TokenInfo::getSource, toUnmodifiableList())); + final List fileTokenInfos = nodesResponse.getFileTokenInfos(); + builder.startObject() .field("service_account", principal) - .field("node_name", nodeName) - .field("count", tokenInfos.size()) + .field("count", indexTokenInfos.size() + fileTokenInfos.size()) .field("tokens").startObject(); - for (TokenInfo info : tokenInfosBySource.getOrDefault(TokenInfo.TokenSource.INDEX, List.of())) { + for (TokenInfo info : indexTokenInfos) { info.toXContent(builder, params); } - builder.endObject().field("file_tokens").startObject(); - for (TokenInfo info : tokenInfosBySource.getOrDefault(TokenInfo.TokenSource.FILE, List.of())) { + builder.endObject().field("nodes_credentials").startObject(); + RestActions.buildNodesHeader(builder, params, nodesResponse); + builder.startObject("file_tokens"); + for (TokenInfo info : fileTokenInfos) { info.toXContent(builder, params); } + builder.endObject(); builder.endObject().endObject(); return builder; } @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - GetServiceAccountCredentialsResponse that = (GetServiceAccountCredentialsResponse) 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); + public String toString() { + return "GetServiceAccountCredentialsResponse{" + "principal='" + + principal + '\'' + ", indexTokenInfos=" + indexTokenInfos + + ", nodesResponse=" + nodesResponse + '}'; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountNodesCredentialsAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountNodesCredentialsAction.java new file mode 100644 index 0000000000000..ae13d8c04038c --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountNodesCredentialsAction.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 GetServiceAccountNodesCredentialsAction extends ActionType { + + public static final String NAME = GetServiceAccountCredentialsAction.NAME + "[n]"; + public static final GetServiceAccountNodesCredentialsAction INSTANCE = new GetServiceAccountNodesCredentialsAction(); + + public GetServiceAccountNodesCredentialsAction() { + super(NAME, GetServiceAccountCredentialsNodesResponse::new); + } +} 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 index 30e3e77b59d84..137954944bc7f 100644 --- 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 @@ -12,24 +12,31 @@ import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.core.Nullable; import java.io.IOException; +import java.util.Collection; import java.util.Map; import java.util.Objects; public class TokenInfo implements Writeable, ToXContentObject, Comparable { private final String name; - private final TokenSource source; + @Nullable + private final Collection nodeNames; - private TokenInfo(String name, TokenSource source) { + private TokenInfo(String name) { + this(name, null); + } + + private TokenInfo(String name, Collection nodeNames) { this.name = name; - this.source = source; + this.nodeNames = nodeNames; } public TokenInfo(StreamInput in) throws IOException { this.name = in.readString(); - this.source = in.readEnum(TokenSource.class); + this.nodeNames = in.readOptionalStringList(); } public String getName() { @@ -37,7 +44,11 @@ public String getName() { } public TokenSource getSource() { - return source; + return nodeNames == null ? TokenSource.INDEX : TokenSource.FILE; + } + + public Collection getNodeNames() { + return nodeNames; } @Override @@ -47,40 +58,50 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TokenInfo tokenInfo = (TokenInfo) o; - return Objects.equals(name, tokenInfo.name) && source == tokenInfo.source; + return Objects.equals(name, tokenInfo.name) && Objects.equals(nodeNames, tokenInfo.nodeNames); } @Override public int hashCode() { - return Objects.hash(name, source); + return Objects.hash(name, nodeNames); + } + + @Override + public String toString() { + return "TokenInfo{" + "name='" + name + '\'' + ", nodeNames=" + nodeNames + '}'; } public static TokenInfo indexToken(String name) { - return new TokenInfo(name, TokenSource.INDEX); + return new TokenInfo(name); } - public static TokenInfo fileToken(String name) { - return new TokenInfo(name, TokenSource.FILE); + public static TokenInfo fileToken(String name, Collection nodeNames) { + return new TokenInfo(name, nodeNames); } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - return builder.field(name, Map.of()); + if (nodeNames == null) { + return builder.field(name, Map.of()); + } else { + return builder.field(name, Map.of("nodes", nodeNames)); + } } @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(name); - out.writeEnum(source); + out.writeOptionalStringCollection(nodeNames); } @Override public int compareTo(TokenInfo o) { - final int score = source.compareTo(o.source); - if (score == 0) { + // Not comparing node names since name and source guarantee unique order + int v = getSource().compareTo(o.getSource()); + if (v == 0) { return name.compareTo(o.name); } else { - return score; + return v; } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponseTests.java index 008f001f5babf..da0a5f3ddc009 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/service/GetServiceAccountCredentialsResponseTests.java @@ -7,121 +7,128 @@ package org.elasticsearch.xpack.core.security.action.service; +import org.elasticsearch.Version; +import org.elasticsearch.action.FailedNodeException; +import org.elasticsearch.cluster.ClusterName; +import org.elasticsearch.cluster.node.DiscoveryNode; import org.elasticsearch.common.bytes.BytesReference; -import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.transport.TransportAddress; 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 org.elasticsearch.test.ESTestCase; import java.io.IOException; +import java.nio.file.NoSuchFileException; import java.util.ArrayList; -import java.util.Collections; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; +import static java.util.stream.Collectors.toUnmodifiableList; import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -public class GetServiceAccountCredentialsResponseTests extends AbstractWireSerializingTestCase { +public class GetServiceAccountCredentialsResponseTests extends ESTestCase { - @Override - protected Writeable.Reader instanceReader() { - return GetServiceAccountCredentialsResponse::new; + public void testSerialization() throws IOException { + final GetServiceAccountCredentialsResponse original = createTestInstance(); + final BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + final GetServiceAccountCredentialsResponse deserialized = new GetServiceAccountCredentialsResponse(out.bytes().streamInput()); + + assertThat(original.getPrincipal(), equalTo(deserialized.getPrincipal())); + assertThat(getAllTokenInfos(original), equalTo(getAllTokenInfos(deserialized))); + assertThat(original.getNodesResponse().getFileTokenInfos(), equalTo(deserialized.getNodesResponse().getFileTokenInfos())); } - @Override - protected GetServiceAccountCredentialsResponse createTestInstance() { + private GetServiceAccountCredentialsResponse 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()) + final List indexTokenInfos = IntStream.range(0, randomIntBetween(0, 10)) + .mapToObj(i -> TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8))) .collect(Collectors.toUnmodifiableList()); - return new GetServiceAccountCredentialsResponse(principal, nodeName, tokenInfos); - } - - @Override - protected GetServiceAccountCredentialsResponse mutateInstance(GetServiceAccountCredentialsResponse instance) throws IOException { - - switch (randomIntBetween(0, 2)) { - case 0: - return new GetServiceAccountCredentialsResponse(randomValueOtherThan(instance.getPrincipal(), - () -> randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8)), - instance.getNodeName(), instance.getTokenInfos()); - case 1: - return new GetServiceAccountCredentialsResponse(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 GetServiceAccountCredentialsResponse(instance.getPrincipal(), instance.getNodeName(), - tokenInfos.stream().collect(Collectors.toUnmodifiableList())); - } - } - - public void testEquals() { - final GetServiceAccountCredentialsResponse response = createTestInstance(); - final ArrayList tokenInfos = new ArrayList<>(response.getTokenInfos()); - Collections.shuffle(tokenInfos, random()); - assertThat(new GetServiceAccountCredentialsResponse( - response.getPrincipal(), response.getNodeName(), tokenInfos.stream().collect(Collectors.toUnmodifiableList())), - equalTo(response)); + final GetServiceAccountCredentialsNodesResponse fileTokensResponse = randomGetServiceAccountFileTokensResponse(); + return new GetServiceAccountCredentialsResponse(principal, indexTokenInfos, fileTokensResponse); } + @SuppressWarnings("unchecked") public void testToXContent() throws IOException { final GetServiceAccountCredentialsResponse response = createTestInstance(); - final Map nameToTokenInfos = response.getTokenInfos().stream() - .collect(Collectors.toMap(TokenInfo::getName, Function.identity())); + final Collection tokenInfos = getAllTokenInfos(response); + 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") + assertThat(responseMap.get("count"), equalTo(tokenInfos.size())); + + final Map nameToTokenInfos = tokenInfos.stream() + .collect(Collectors.toMap(TokenInfo::getName, Function.identity())); + 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))); + final Map nodes = (Map) responseMap.get("nodes_credentials"); + final Map nodesHeader = (Map) nodes.get("_nodes"); + assertThat(nodesHeader.get("successful"), equalTo(response.getNodesResponse().getNodes().size())); + assertThat(nodesHeader.get("failed"), equalTo(response.getNodesResponse().failures().size())); + final Map fileTokens = (Map) nodes.get("file_tokens"); + assertNotNull(fileTokens); + fileTokens.forEach((key, value) -> { + final Map tokenContent = (Map) value; + assertThat(tokenContent.get("nodes"), equalTo(nameToTokenInfos.get(key).getNodeNames())); + assertThat(nameToTokenInfos.remove(key).getSource(), equalTo(TokenInfo.TokenSource.FILE)); + }); assertThat(nameToTokenInfos, is(anEmptyMap())); } - private TokenInfo randomTokenInfo() { - return randomBoolean() ? - TokenInfo.fileToken(randomAlphaOfLengthBetween(3, 8)) : - TokenInfo.indexToken(randomAlphaOfLengthBetween(3, 8)); + private GetServiceAccountCredentialsNodesResponse randomGetServiceAccountFileTokensResponse() { + final ClusterName clusterName = new ClusterName(randomAlphaOfLength(8)); + final int total = randomIntBetween(1, 5); + final int nFailures = randomIntBetween(0, 5); + final String[] tokenNames = randomArray(0, 10, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)); + + final ArrayList nodes = new ArrayList<>(); + for (int i = 0; i < total - nFailures; i++) { + final GetServiceAccountCredentialsNodesResponse.Node node = randomNodeResponse(tokenNames, i); + nodes.add(node); + } + + final ArrayList failures = new ArrayList<>(); + for (int i = 0; i < nFailures; i++) { + final FailedNodeException e = randomFailedNodeException(i); + failures.add(e); + } + return new GetServiceAccountCredentialsNodesResponse(clusterName, nodes, failures); + } + + private FailedNodeException randomFailedNodeException(int i) { + return new FailedNodeException(randomAlphaOfLength(9) + i, randomAlphaOfLength(20), new NoSuchFileException("service_tokens")); + } + + private GetServiceAccountCredentialsNodesResponse.Node randomNodeResponse(String[] tokenNames, int i) { + final DiscoveryNode discoveryNode = new DiscoveryNode( + randomAlphaOfLength(8) + i, + new TransportAddress(TransportAddress.META_ADDRESS, 9300), + Version.CURRENT); + return new GetServiceAccountCredentialsNodesResponse.Node( + discoveryNode, + randomSubsetOf(randomIntBetween(0, tokenNames.length), tokenNames).toArray(String[]::new)); + } + + private List getAllTokenInfos(GetServiceAccountCredentialsResponse response) { + return Stream.concat(response.getNodesResponse().getFileTokenInfos().stream(), response.getIndexTokenInfos().stream()) + .collect(toUnmodifiableList()); } } 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 7a135424c160f..6bee9a5bc2bc0 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 @@ -201,6 +201,7 @@ public class Constants { "cluster:admin/xpack/security/saml/prepare", "cluster:admin/xpack/security/service_account/get", "cluster:admin/xpack/security/service_account/credential/get", + "cluster:admin/xpack/security/service_account/credential/get[n]", "cluster:admin/xpack/security/service_account/token/create", "cluster:admin/xpack/security/service_account/token/delete", "cluster:admin/xpack/security/token/create", 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 476a0316c83d0..d73b05ba1488c 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 @@ -35,7 +35,9 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; public class ServiceAccountIT extends ESRestTestCase { @@ -299,7 +301,7 @@ public void testGetServiceAccountCredentials() throws IOException { 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()))); + assertNodesCredentials(getTokensResponseMap1); final Request createTokenRequest1 = new Request("POST", "_security/service/elastic/fleet-server/credential/token/api-token-1"); final Response createTokenResponse1 = client().performRequest(createTokenRequest1); @@ -314,11 +316,11 @@ public void testGetServiceAccountCredentials() throws IOException { 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() ))); + assertNodesCredentials(getTokensResponseMap2); final Request deleteTokenRequest1 = new Request("DELETE", "_security/service/elastic/fleet-server/credential/token/api-token-2"); final Response deleteTokenResponse1 = client().performRequest(deleteTokenRequest1); @@ -330,10 +332,10 @@ public void testGetServiceAccountCredentials() throws IOException { final Map getTokensResponseMap3 = responseAsMap(getTokensResponse3); assertThat(getTokensResponseMap3.get("service_account"), equalTo("elastic/fleet-server")); assertThat(getTokensResponseMap3.get("count"), equalTo(2)); - assertThat(getTokensResponseMap3.get("file_tokens"), equalTo(Map.of("token1", Map.of()))); assertThat(getTokensResponseMap3.get("tokens"), equalTo(Map.of( "api-token-1", Map.of() ))); + assertNodesCredentials(getTokensResponseMap3); final Request deleteTokenRequest2 = new Request("DELETE", "_security/service/elastic/fleet-server/credential/token/non-such-thing"); final ResponseException e2 = expectThrows(ResponseException.class, () -> client().performRequest(deleteTokenRequest2)); @@ -419,4 +421,19 @@ private void assertServiceAccountRoleDescriptor(Response response, assertThat(responseMap, hasEntry(serviceAccountPrincipal, Map.of("role_descriptor", XContentHelper.convertToMap(new BytesArray(roleDescriptorString), false, XContentType.JSON).v2()))); } + + @SuppressWarnings("unchecked") + private void assertNodesCredentials(Map responseMap) { + final Map nodes = (Map) responseMap.get("nodes_credentials"); + assertThat(nodes, hasKey("_nodes")); + final Map header = (Map) nodes.get("_nodes"); + assertThat(header.get("total"), equalTo(2)); + assertThat(header.get("successful"), equalTo(2)); + assertThat(header.get("failed"), equalTo(0)); + assertThat(header.get("failures"), nullValue()); + final Map fileTokens = (Map) nodes.get("file_tokens"); + assertThat(fileTokens, hasKey("token1")); + final Map token1 = (Map) fileTokens.get("token1"); + assertThat((List) token1.get("nodes"), equalTo(List.of("javaRestTest-0", "javaRestTest-1"))); + } } 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 a5a3ab127302f..5306b67e76ba9 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 @@ -124,6 +124,7 @@ import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenAction; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountAction; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountNodesCredentialsAction; 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; @@ -197,6 +198,7 @@ import org.elasticsearch.xpack.security.action.service.TransportDeleteServiceAccountTokenAction; import org.elasticsearch.xpack.security.action.service.TransportGetServiceAccountAction; import org.elasticsearch.xpack.security.action.service.TransportGetServiceAccountCredentialsAction; +import org.elasticsearch.xpack.security.action.service.TransportGetServiceAccountNodesCredentialsAction; import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction; import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction; import org.elasticsearch.xpack.security.action.token.TransportRefreshTokenAction; @@ -222,7 +224,6 @@ import org.elasticsearch.xpack.security.authc.service.FileServiceAccountTokenStore; import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountTokenStore; import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; -import org.elasticsearch.xpack.security.authc.service.CompositeServiceAccountTokenStore; 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; @@ -535,10 +536,11 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste components.add(indexServiceAccountTokenStore); final FileServiceAccountTokenStore fileServiceAccountTokenStore = - new FileServiceAccountTokenStore(environment, resourceWatcherService, threadPool, cacheInvalidatorRegistry); + new FileServiceAccountTokenStore(environment, resourceWatcherService, threadPool, clusterService, cacheInvalidatorRegistry); + components.add(fileServiceAccountTokenStore); - final ServiceAccountService serviceAccountService = new ServiceAccountService(new CompositeServiceAccountTokenStore( - List.of(fileServiceAccountTokenStore, indexServiceAccountTokenStore), threadPool.getThreadContext()), httpTlsRuntimeCheck); + final ServiceAccountService serviceAccountService = new ServiceAccountService(client, + fileServiceAccountTokenStore, indexServiceAccountTokenStore, httpTlsRuntimeCheck); components.add(serviceAccountService); final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, @@ -901,6 +903,8 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class), new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class), new ActionHandler<>(GetServiceAccountCredentialsAction.INSTANCE, TransportGetServiceAccountCredentialsAction.class), + new ActionHandler<>(GetServiceAccountNodesCredentialsAction.INSTANCE, + TransportGetServiceAccountNodesCredentialsAction.class), new ActionHandler<>(GetServiceAccountAction.INSTANCE, TransportGetServiceAccountAction.class), new ActionHandler<>(KibanaEnrollmentAction.INSTANCE, TransportKibanaEnrollmentAction.class), new ActionHandler<>(NodeEnrollmentAction.INSTANCE, TransportNodeEnrollmentAction.class), 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 index c5adc779ae947..18f134ede57e6 100644 --- 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 @@ -18,37 +18,31 @@ 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.IndexServiceAccountTokenStore; -import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; public class TransportCreateServiceAccountTokenAction extends HandledTransportAction { - private final IndexServiceAccountTokenStore indexServiceAccountTokenStore; + private final ServiceAccountService serviceAccountService; private final SecurityContext securityContext; - private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; @Inject public TransportCreateServiceAccountTokenAction(TransportService transportService, ActionFilters actionFilters, - IndexServiceAccountTokenStore indexServiceAccountTokenStore, - SecurityContext securityContext, - HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + ServiceAccountService serviceAccountService, + SecurityContext securityContext) { super(CreateServiceAccountTokenAction.NAME, transportService, actionFilters, CreateServiceAccountTokenRequest::new); - this.indexServiceAccountTokenStore = indexServiceAccountTokenStore; + this.serviceAccountService = serviceAccountService; 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 { - indexServiceAccountTokenStore.createToken(authentication, request, listener); - } - }); + final Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + listener.onFailure(new IllegalStateException("authentication is required")); + } else { + serviceAccountService.createIndexToken(authentication, request, listener); + } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenAction.java index a87daff4faef9..33f15325cce5f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenAction.java @@ -16,31 +16,24 @@ import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenAction; import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenRequest; import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenResponse; -import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountTokenStore; -import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; public class TransportDeleteServiceAccountTokenAction extends HandledTransportAction { - private final IndexServiceAccountTokenStore indexServiceAccountTokenStore; - private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; + private final ServiceAccountService serviceAccountService; @Inject public TransportDeleteServiceAccountTokenAction(TransportService transportService, ActionFilters actionFilters, - IndexServiceAccountTokenStore indexServiceAccountTokenStore, - HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + ServiceAccountService serviceAccountService) { super(DeleteServiceAccountTokenAction.NAME, transportService, actionFilters, DeleteServiceAccountTokenRequest::new); - this.indexServiceAccountTokenStore = indexServiceAccountTokenStore; - this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; + this.serviceAccountService = serviceAccountService; } @Override protected void doExecute(Task task, DeleteServiceAccountTokenRequest request, ActionListener listener) { - httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "delete service account token", () -> { - indexServiceAccountTokenStore.deleteToken(request, ActionListener.wrap(found -> { - listener.onResponse(new DeleteServiceAccountTokenResponse(found)); - }, listener::onFailure)); - }); + serviceAccountService.deleteIndexToken(request, ActionListener.wrap(found -> + listener.onResponse(new DeleteServiceAccountTokenResponse(found)), listener::onFailure)); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsAction.java index 95a3b3a50996a..7182a41f0bd48 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsAction.java @@ -11,41 +11,28 @@ 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.GetServiceAccountCredentialsAction; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsRequest; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse; -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 TransportGetServiceAccountCredentialsAction extends HandledTransportAction { private final ServiceAccountService serviceAccountService; - private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; - private final String nodeName; @Inject public TransportGetServiceAccountCredentialsAction(TransportService transportService, ActionFilters actionFilters, - Settings settings, - ServiceAccountService serviceAccountService, - HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + ServiceAccountService serviceAccountService) { super(GetServiceAccountCredentialsAction.NAME, transportService, actionFilters, GetServiceAccountCredentialsRequest::new); - this.nodeName = Node.NODE_NAME_SETTING.get(settings); this.serviceAccountService = serviceAccountService; - this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; } @Override protected void doExecute(Task task, GetServiceAccountCredentialsRequest 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); - }); + serviceAccountService.findTokensFor(request, listener); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountNodesCredentialsAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountNodesCredentialsAction.java new file mode 100644 index 0000000000000..0f52be45442d7 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountNodesCredentialsAction.java @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.action.service; + +import org.elasticsearch.action.FailedNodeException; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.nodes.TransportNodesAction; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountNodesCredentialsAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesResponse; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; +import org.elasticsearch.xpack.security.authc.service.FileServiceAccountTokenStore; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.io.IOException; +import java.util.List; + +/** + * This action handler is to retrieve service account credentials that are local to the node. + * Currently this means file-backed service tokens. + */ +public class TransportGetServiceAccountNodesCredentialsAction + extends TransportNodesAction { + + private final FileServiceAccountTokenStore fileServiceAccountTokenStore; + + @Inject + public TransportGetServiceAccountNodesCredentialsAction(ThreadPool threadPool, ClusterService clusterService, + TransportService transportService, ActionFilters actionFilters, + FileServiceAccountTokenStore fileServiceAccountTokenStore) { + super( + GetServiceAccountNodesCredentialsAction.NAME, threadPool, clusterService, transportService, actionFilters, + GetServiceAccountCredentialsNodesRequest::new, GetServiceAccountCredentialsNodesRequest.Node::new, + ThreadPool.Names.SAME, GetServiceAccountCredentialsNodesResponse.Node.class); + this.fileServiceAccountTokenStore = fileServiceAccountTokenStore; + } + + @Override + protected GetServiceAccountCredentialsNodesResponse newResponse( + GetServiceAccountCredentialsNodesRequest request, + List nodes, + List failures) { + return new GetServiceAccountCredentialsNodesResponse(clusterService.getClusterName(), nodes, failures); + } + + @Override + protected GetServiceAccountCredentialsNodesRequest.Node newNodeRequest(GetServiceAccountCredentialsNodesRequest request) { + return new GetServiceAccountCredentialsNodesRequest.Node(request); + } + + @Override + protected GetServiceAccountCredentialsNodesResponse.Node newNodeResponse(StreamInput in) throws IOException { + return new GetServiceAccountCredentialsNodesResponse.Node(in); + } + + @Override + protected GetServiceAccountCredentialsNodesResponse.Node nodeOperation( + GetServiceAccountCredentialsNodesRequest.Node request, Task task + ) { + final ServiceAccountId accountId = new ServiceAccountId(request.getNamespace(), request.getServiceName()); + final List tokenInfos = fileServiceAccountTokenStore.findTokensFor(accountId); + return new GetServiceAccountCredentialsNodesResponse.Node( + clusterService.localNode(), + tokenInfos.stream().map(TokenInfo::getName).toArray(String[]::new)); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStore.java index 5942da34fa5bd..5b27239e74454 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStore.java @@ -13,24 +13,18 @@ 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 CompositeServiceAccountTokenStore implements ServiceAccountTokenStore { - private static final Logger logger = - LogManager.getLogger(CompositeServiceAccountTokenStore.class); + private static final Logger logger = LogManager.getLogger(CompositeServiceAccountTokenStore.class); private final ThreadContext threadContext; private final List stores; - public CompositeServiceAccountTokenStore( - List stores, ThreadContext threadContext) { + public CompositeServiceAccountTokenStore(List stores, ThreadContext threadContext) { this.stores = stores; this.threadContext = threadContext; } @@ -53,52 +47,4 @@ public void authenticate(ServiceAccountToken token, 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/FileServiceAccountTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStore.java index ea123421e66dd..bbb0d3dc7fa52 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStore.java @@ -11,6 +11,7 @@ import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchException; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.core.Nullable; import org.elasticsearch.common.Strings; import org.elasticsearch.common.util.Maps; @@ -32,7 +33,6 @@ 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; @@ -46,12 +46,14 @@ public class FileServiceAccountTokenStore extends CachingServiceAccountTokenStor private static final Logger logger = LogManager.getLogger(FileServiceAccountTokenStore.class); private final Path file; + private final ClusterService clusterService; private final CopyOnWriteArrayList refreshListeners; private volatile Map tokenHashes; public FileServiceAccountTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool, - CacheInvalidatorRegistry cacheInvalidatorRegistry) { + ClusterService clusterService, CacheInvalidatorRegistry cacheInvalidatorRegistry) { super(env.settings(), threadPool); + this.clusterService = clusterService; file = resolveFile(env); FileWatcher watcher = new FileWatcher(file.getParent()); watcher.addListener(new FileReloadListener(file, this::tryReload)); @@ -83,15 +85,15 @@ public TokenSource getTokenSource() { return TokenSource.FILE; } - @Override - public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + public List findTokensFor(ServiceAccountId accountId) { final String principal = accountId.asPrincipal(); - final List tokenInfos = tokenHashes.keySet() + return tokenHashes.keySet() .stream() .filter(k -> k.startsWith(principal + "/")) - .map(k -> TokenInfo.fileToken(Strings.substring(k, principal.length() + 1, k.length()))) + .map(k -> TokenInfo.fileToken( + Strings.substring(k, principal.length() + 1, k.length()), + List.of(clusterService.localNode().getName()))) .collect(Collectors.toUnmodifiableList()); - listener.onResponse(tokenInfos); } public void addListener(Runnable listener) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountTokenStore.java index b698cd4c31dfe..ca64552dffbcf 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/IndexServiceAccountTokenStore.java @@ -114,7 +114,7 @@ public TokenSource getTokenSource() { return TokenSource.INDEX; } - public void createToken(Authentication authentication, CreateServiceAccountTokenRequest request, + void createToken(Authentication authentication, CreateServiceAccountTokenRequest request, ActionListener listener) { final ServiceAccountId accountId = new ServiceAccountId(request.getNamespace(), request.getServiceName()); if (false == ServiceAccountService.isServiceAccountPrincipal(accountId.asPrincipal())) { @@ -146,8 +146,7 @@ public void createToken(Authentication authentication, CreateServiceAccountToken } } - @Override - public void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { + void findTokensFor(ServiceAccountId accountId, ActionListener> listener) { final SecurityIndexManager frozenSecurityIndex = this.securityIndex.freeze(); if (false == frozenSecurityIndex.indexExists()) { listener.onResponse(List.of()); @@ -179,7 +178,7 @@ public void findTokensFor(ServiceAccountId accountId, ActionListener listener) { + void deleteToken(DeleteServiceAccountTokenRequest request, ActionListener listener) { final SecurityIndexManager frozenSecurityIndex = this.securityIndex.freeze(); if (false == frozenSecurityIndex.indexExists()) { listener.onResponse(false); 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 7fb9f7d23affe..20d818cc34145 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 @@ -12,9 +12,17 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.rest.RestStatus; +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.DeleteServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsRequest; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountNodesCredentialsAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesRequest; +import org.elasticsearch.xpack.core.security.action.service.TokenInfo; import org.elasticsearch.xpack.core.security.action.service.TokenInfo.TokenSource; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings; @@ -24,9 +32,12 @@ import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; import java.util.Collection; +import java.util.List; import java.util.Locale; import java.util.Map; +import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; import static org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings.TOKEN_NAME_FIELD; import static org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings.TOKEN_SOURCE_FIELD; import static org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ACCOUNTS; @@ -36,11 +47,19 @@ public class ServiceAccountService { private static final Logger logger = LogManager.getLogger(ServiceAccountService.class); private static final int MIN_TOKEN_SECRET_LENGTH = 10; - private final ServiceAccountTokenStore serviceAccountTokenStore; + private final Client client; + private final IndexServiceAccountTokenStore indexServiceAccountTokenStore; + private final CompositeServiceAccountTokenStore compositeServiceAccountTokenStore; private final HttpTlsRuntimeCheck httpTlsRuntimeCheck; - public ServiceAccountService(ServiceAccountTokenStore serviceAccountTokenStore, HttpTlsRuntimeCheck httpTlsRuntimeCheck) { - this.serviceAccountTokenStore = serviceAccountTokenStore; + public ServiceAccountService(Client client, + FileServiceAccountTokenStore fileServiceAccountTokenStore, + IndexServiceAccountTokenStore indexServiceAccountTokenStore, + HttpTlsRuntimeCheck httpTlsRuntimeCheck) { + this.client = client; + this.indexServiceAccountTokenStore = indexServiceAccountTokenStore; + this.compositeServiceAccountTokenStore = new CompositeServiceAccountTokenStore( + List.of(fileServiceAccountTokenStore, indexServiceAccountTokenStore), client.threadPool().getThreadContext()); this.httpTlsRuntimeCheck = httpTlsRuntimeCheck; } @@ -82,12 +101,6 @@ public static ServiceAccountToken tryParseToken(SecureString bearerString) { } } - public void findTokensFor(ServiceAccountId accountId, String nodeName, ActionListener listener) { - serviceAccountTokenStore.findTokensFor(accountId, ActionListener.wrap(tokenInfos -> { - listener.onResponse(new GetServiceAccountCredentialsResponse(accountId.asPrincipal(), nodeName, tokenInfos)); - }, listener::onFailure)); - } - public void authenticateToken(ServiceAccountToken serviceAccountToken, String nodeName, ActionListener listener) { logger.trace("attempt to authenticate service account token [{}]", serviceAccountToken.getQualifiedName()); httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "service account authentication", () -> { @@ -116,7 +129,7 @@ public void authenticateToken(ServiceAccountToken serviceAccountToken, String no return; } - serviceAccountTokenStore.authenticate(serviceAccountToken, ActionListener.wrap(storeAuthenticationResult -> { + compositeServiceAccountTokenStore.authenticate(serviceAccountToken, ActionListener.wrap(storeAuthenticationResult -> { if (storeAuthenticationResult.isSuccess()) { listener.onResponse( createAuthentication(account, serviceAccountToken, storeAuthenticationResult.getTokenSource() , nodeName)); @@ -129,6 +142,28 @@ public void authenticateToken(ServiceAccountToken serviceAccountToken, String no }); } + public void createIndexToken(Authentication authentication, CreateServiceAccountTokenRequest request, + ActionListener listener) { + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, + "create index-backed service token", + () -> indexServiceAccountTokenStore.createToken(authentication, request, listener)); + } + + public void deleteIndexToken(DeleteServiceAccountTokenRequest request, ActionListener listener) { + httpTlsRuntimeCheck.checkTlsThenExecute( + listener::onFailure, + "delete index-backed service token", + () -> indexServiceAccountTokenStore.deleteToken(request, listener)); + } + + public void findTokensFor(GetServiceAccountCredentialsRequest request, + ActionListener listener) { + httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "find service tokens", () -> { + final ServiceAccountId accountId = new ServiceAccountId(request.getNamespace(), request.getServiceName()); + findIndexTokens(accountId, listener); + }); + } + public void getRoleDescriptor(Authentication authentication, ActionListener listener) { assert authentication.isServiceAccount() : "authentication is not for service account: " + authentication; httpTlsRuntimeCheck.checkTlsThenExecute(listener::onFailure, "service account role descriptor resolving", () -> { @@ -158,4 +193,21 @@ private ElasticsearchSecurityException createAuthenticationException(ServiceAcco serviceAccountToken.getAccountId().asPrincipal(), serviceAccountToken.getTokenName()); } + + private void findIndexTokens(ServiceAccountId accountId, ActionListener listener) { + indexServiceAccountTokenStore.findTokensFor(accountId, ActionListener.wrap(indexTokenInfos -> { + findFileTokens(indexTokenInfos, accountId, listener); + }, listener::onFailure)); + } + + private void findFileTokens( Collection indexTokenInfos, + ServiceAccountId accountId, + ActionListener listener) { + executeAsyncWithOrigin(client, SECURITY_ORIGIN, + GetServiceAccountNodesCredentialsAction.INSTANCE, + new GetServiceAccountCredentialsNodesRequest(accountId.namespace(), accountId.serviceName()), + ActionListener.wrap(fileTokensResponse -> listener.onResponse( + new GetServiceAccountCredentialsResponse(accountId.asPrincipal(), indexTokenInfos, fileTokensResponse)), + listener::onFailure)); + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenStore.java index ab526831dcbc7..37eec6a092e3f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenStore.java @@ -8,11 +8,7 @@ package org.elasticsearch.xpack.security.authc.service; import org.elasticsearch.action.ActionListener; -import org.elasticsearch.xpack.core.security.action.service.TokenInfo; import org.elasticsearch.xpack.core.security.action.service.TokenInfo.TokenSource; -import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; - -import java.util.Collection; /** * The interface should be implemented by credential stores of different backends. @@ -24,11 +20,6 @@ public interface ServiceAccountTokenStore { */ void authenticate(ServiceAccountToken token, ActionListener listener); - /** - * Get all tokens belong to the given service account id - */ - void findTokensFor(ServiceAccountId accountId, ActionListener> listener); - class StoreAuthenticationResult { private final boolean success; private final TokenSource tokenSource; 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 index 4c3493924027f..8747188e8a1fc 100644 --- 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 @@ -7,29 +7,20 @@ 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.core.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.IndexServiceAccountTokenStore; -import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; 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; @@ -39,35 +30,18 @@ public class TransportCreateServiceAccountTokenActionTests extends ESTestCase { - private IndexServiceAccountTokenStore indexServiceAccountTokenStore; + private ServiceAccountService serviceAccountService; private SecurityContext securityContext; private TransportCreateServiceAccountTokenAction transportCreateServiceAccountTokenAction; - private Transport transport; @Before @SuppressForbidden(reason = "Allow accessing localhost") public void init() throws IOException { - indexServiceAccountTokenStore = mock(IndexServiceAccountTokenStore.class); + serviceAccountService = mock(ServiceAccountService.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()), - indexServiceAccountTokenStore, securityContext, new HttpTlsRuntimeCheck(builder.build(), new SetOnce<>(transport))); + serviceAccountService, securityContext); } public void testAuthenticationIsRequired() { @@ -84,25 +58,6 @@ public void testExecutionWillDelegate() { final CreateServiceAccountTokenRequest request = mock(CreateServiceAccountTokenRequest.class); final PlainActionFuture future = new PlainActionFuture<>(); transportCreateServiceAccountTokenAction.doExecute(mock(Task.class), request, future); - verify(indexServiceAccountTokenStore).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()), - indexServiceAccountTokenStore, 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")); + verify(serviceAccountService).createIndexToken(authentication, request, future); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenActionTests.java index 3c191514be1a5..f58d9feab50c1 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportDeleteServiceAccountTokenActionTests.java @@ -13,38 +13,27 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.transport.TransportService; import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenRequest; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.core.security.action.service.DeleteServiceAccountTokenResponse; -import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountTokenStore; -import org.elasticsearch.xpack.security.authc.support.HttpTlsRuntimeCheck; import org.junit.Before; import java.util.Collections; import static org.elasticsearch.test.ActionListenerUtils.anyActionListener; -import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; public class TransportDeleteServiceAccountTokenActionTests extends ESTestCase { - private IndexServiceAccountTokenStore indexServiceAccountTokenStore; - private HttpTlsRuntimeCheck httpTlsRuntimeCheck; + private ServiceAccountService serviceAccountService; private TransportDeleteServiceAccountTokenAction transportDeleteServiceAccountTokenAction; @Before public void init() { - indexServiceAccountTokenStore = mock(IndexServiceAccountTokenStore.class); - httpTlsRuntimeCheck = mock(HttpTlsRuntimeCheck.class); + serviceAccountService = mock(ServiceAccountService.class); transportDeleteServiceAccountTokenAction = new TransportDeleteServiceAccountTokenAction( - mock(TransportService.class), new ActionFilters(Collections.emptySet()), indexServiceAccountTokenStore, httpTlsRuntimeCheck); - - doAnswer(invocationOnMock -> { - final Object[] arguments = invocationOnMock.getArguments(); - ((Runnable) arguments[2]).run(); - return null; - }).when(httpTlsRuntimeCheck).checkTlsThenExecute(any(), any(), any()); + mock(TransportService.class), new ActionFilters(Collections.emptySet()), serviceAccountService); } public void testDoExecuteWillDelegate() { @@ -52,7 +41,6 @@ public void testDoExecuteWillDelegate() { randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); @SuppressWarnings("unchecked") final ActionListener listener = mock(ActionListener.class); transportDeleteServiceAccountTokenAction.doExecute(mock(Task.class), request, listener); - verify(indexServiceAccountTokenStore).deleteToken(eq(request), anyActionListener()); + verify(serviceAccountService).deleteIndexToken(eq(request), anyActionListener()); } - } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsActionTests.java index 4122ff6072200..f99519f885c62 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/service/TransportGetServiceAccountCredentialsActionTests.java @@ -7,11 +7,8 @@ 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.settings.Settings; import org.elasticsearch.common.transport.BoundTransportAddress; import org.elasticsearch.common.transport.TransportAddress; @@ -22,17 +19,13 @@ import org.elasticsearch.transport.TransportService; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsRequest; import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse; -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; @@ -67,8 +60,7 @@ public void init() throws UnknownHostException { final Settings settings = builder.build(); serviceAccountService = mock(ServiceAccountService.class); transportGetServiceAccountCredentialsAction = new TransportGetServiceAccountCredentialsAction( - mock(TransportService.class), new ActionFilters(Collections.emptySet()), - settings, serviceAccountService, new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); + mock(TransportService.class), new ActionFilters(Collections.emptySet()), serviceAccountService); } public void testDoExecuteWillDelegate() { @@ -77,26 +69,6 @@ public void testDoExecuteWillDelegate() { @SuppressWarnings("unchecked") final ActionListener listener = mock(ActionListener.class); transportGetServiceAccountCredentialsAction.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 TransportGetServiceAccountCredentialsAction action = new TransportGetServiceAccountCredentialsAction( - 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(GetServiceAccountCredentialsRequest.class), future); - final ElasticsearchException e = expectThrows(ElasticsearchException.class, future::actionGet); - assertThat(e.getMessage(), containsString("[get service account tokens] requires TLS for the HTTP interface")); + verify(serviceAccountService).findTokensFor(eq(request), eq(listener)); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountTokenStoreTests.java index 38fa4563e892b..5eceaf7082446 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountTokenStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountTokenStoreTests.java @@ -16,7 +16,6 @@ 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.core.security.action.service.TokenInfo.TokenSource; import org.elasticsearch.xpack.core.security.support.ValidationTests; import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; @@ -25,7 +24,6 @@ import org.junit.Before; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; @@ -73,11 +71,6 @@ void doAuthenticate(ServiceAccountToken token, ActionListener> listener) { - listener.onFailure(new UnsupportedOperationException()); - } - @Override TokenSource getTokenSource() { return tokenSource; @@ -170,11 +163,6 @@ void doAuthenticate(ServiceAccountToken token, ActionListener> listener) { - listener.onFailure(new UnsupportedOperationException()); - } - @Override TokenSource getTokenSource() { return tokenSource; @@ -196,11 +184,6 @@ void doAuthenticate(ServiceAccountToken token, ActionListener> listener) { - listener.onFailure(new UnsupportedOperationException()); - } - @Override TokenSource getTokenSource() { return randomFrom(TokenSource.values()); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStoreTests.java index efac58e017716..7448404131339 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountTokenStoreTests.java @@ -12,22 +12,14 @@ 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.core.security.action.service.TokenInfo.TokenSource; -import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; import org.elasticsearch.xpack.security.authc.service.ServiceAccountTokenStore.StoreAuthenticationResult; 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; @@ -111,78 +103,4 @@ 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/FileServiceAccountTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStoreTests.java index e6ef8a12a6bf5..f9eebb5c3f987 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountTokenStoreTests.java @@ -9,7 +9,8 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Logger; -import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.cluster.node.DiscoveryNode; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.env.Environment; @@ -33,10 +34,12 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.util.Collection; +import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.concurrent.atomic.AtomicInteger; import static org.hamcrest.Matchers.containsString; @@ -46,20 +49,14 @@ import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class FileServiceAccountTokenStoreTests extends ESTestCase { - private static Map TOKENS = Map.of( - "bcrypt", "46ToAwIHZWxhc3RpYwVmbGVldAZiY3J5cHQWWEU5MGVBYW9UMWlXMVctdkpmMzRxdwAAAAAAAAA", - "bcrypt10", "46ToAwIHZWxhc3RpYwVmbGVldAhiY3J5cHQxMBY1MmVqWGxhelJCYWZMdXpHTTVoRmNnAAAAAAAAAAAAAAAAAA", - "pbkdf2", "46ToAwIHZWxhc3RpYwVmbGVldAZwYmtkZjIWNURqUkNfWFJTQXFsNUhsYW1weXY3UQAAAAAAAAA", - "pbkdf2_50000", "46ToAwIHZWxhc3RpYwVmbGVldAxwYmtkZjJfNTAwMDAWd24wWGZ4NUlSSHkybE9LU2N2ZndyZwAAAAAAAAAAAA", - "pbkdf2_stretch", "46ToAwIHZWxhc3RpYwVmbGVldA5wYmtkZjJfc3RyZXRjaBZhSV8wUUxSZlJ5R0JQMVU2MFNieTJ3AAAAAAAAAA" - ); - private Settings settings; private Environment env; private ThreadPool threadPool; + private ClusterService clusterService; @Before public void init() { @@ -72,6 +69,10 @@ public void init() { .build(); env = TestEnvironment.newEnvironment(settings); threadPool = new TestThreadPool("test"); + clusterService = mock(ClusterService.class); + final DiscoveryNode discoveryNode = mock(DiscoveryNode.class); + when(clusterService.localNode()).thenReturn(discoveryNode); + when(discoveryNode.getName()).thenReturn("node"); } @After @@ -123,7 +124,7 @@ public void testAutoReload() throws Exception { final AtomicInteger counter = new AtomicInteger(0); FileServiceAccountTokenStore store = new FileServiceAccountTokenStore(env, watcherService, threadPool, - mock(CacheInvalidatorRegistry.class)); + clusterService, mock(CacheInvalidatorRegistry.class)); store.addListener(counter::getAndIncrement); //Token name shares the hashing algorithm name for convenience final String qualifiedTokenName = "elastic/fleet-server/" + hashingAlgo; @@ -209,12 +210,16 @@ public void testFindTokensFor() throws IOException { Path targetFile = configDir.resolve("service_tokens"); Files.copy(serviceTokensSourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); FileServiceAccountTokenStore store = new FileServiceAccountTokenStore(env, mock(ResourceWatcherService.class), threadPool, - mock(CacheInvalidatorRegistry.class)); + clusterService, mock(CacheInvalidatorRegistry.class)); 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)); + final List tokenInfos = store.findTokensFor(accountId); + assertThat(tokenInfos.size(), equalTo(5)); + assertThat(tokenInfos.stream().map(TokenInfo::getName).collect(Collectors.toUnmodifiableSet()), + equalTo(Set.of("pbkdf2", "bcrypt10", "pbkdf2_stretch", "pbkdf2_50000", "bcrypt"))); + assertThat(tokenInfos.stream().map(TokenInfo::getSource).collect(Collectors.toUnmodifiableSet()), + equalTo(EnumSet.of(TokenInfo.TokenSource.FILE))); + assertThat(tokenInfos.stream().map(TokenInfo::getNodeNames).collect(Collectors.toUnmodifiableSet()), + equalTo(Set.of(List.of("node")))); } } 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 fb4ea9e09251b..d2c5e25be76ee 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 @@ -16,17 +16,27 @@ import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.client.Client; import org.elasticsearch.common.Strings; -import org.elasticsearch.core.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.core.SuppressForbidden; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.MockLogAppender; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.Transport; +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.DeleteServiceAccountTokenRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsResponse; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountNodesCredentialsAction; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesRequest; +import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesResponse; import org.elasticsearch.xpack.core.security.action.service.TokenInfo; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings; @@ -35,6 +45,7 @@ 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.After; import org.junit.Before; import java.io.ByteArrayOutputStream; @@ -44,11 +55,15 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -57,20 +72,26 @@ import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ServiceAccountServiceTests extends ESTestCase { - private ThreadContext threadContext; - private ServiceAccountTokenStore serviceAccountTokenStore; + private Client client; + private ThreadPool threadPool; + private FileServiceAccountTokenStore fileServiceAccountTokenStore; + private IndexServiceAccountTokenStore indexServiceAccountTokenStore; private ServiceAccountService serviceAccountService; private Transport transport; @Before @SuppressForbidden(reason = "Allow accessing localhost") public void init() throws UnknownHostException { - threadContext = new ThreadContext(Settings.EMPTY); - serviceAccountTokenStore = mock(ServiceAccountTokenStore.class); + threadPool = new TestThreadPool("service account service tests"); + fileServiceAccountTokenStore = mock(FileServiceAccountTokenStore.class); + indexServiceAccountTokenStore = mock(IndexServiceAccountTokenStore.class); + when(fileServiceAccountTokenStore.getTokenSource()).thenReturn(TokenInfo.TokenSource.FILE); + when(indexServiceAccountTokenStore.getTokenSource()).thenReturn(TokenInfo.TokenSource.INDEX); final Settings.Builder builder = Settings.builder() .put("xpack.security.enabled", true); transport = mock(Transport.class); @@ -87,11 +108,18 @@ public void init() throws UnknownHostException { } when(transport.boundAddress()).thenReturn( new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); - serviceAccountService = new ServiceAccountService( - serviceAccountTokenStore, + client = mock(Client.class); + when(client.threadPool()).thenReturn(threadPool); + serviceAccountService = new ServiceAccountService(client, + fileServiceAccountTokenStore, indexServiceAccountTokenStore, new HttpTlsRuntimeCheck(builder.build(), new SetOnce<>(transport))); } + @After + public void stopThreadPool() { + terminate(threadPool); + } + public void testGetServiceAccountPrincipals() { assertThat(ServiceAccountService.getServiceAccountPrincipals(), equalTo(Set.of("elastic/fleet-server"))); } @@ -251,15 +279,19 @@ public void testTryParseToken() throws IOException, IllegalAccessException { public void testTryAuthenticateBearerToken() throws ExecutionException, InterruptedException { // Valid token final PlainActionFuture future5 = new PlainActionFuture<>(); - final TokenInfo.TokenSource tokenSource = randomFrom(TokenInfo.TokenSource.values()); - doAnswer(invocationOnMock -> { - @SuppressWarnings("unchecked") - final ActionListener listener = - (ActionListener) invocationOnMock.getArguments()[1]; - listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(true, tokenSource)); - return null; - }).when(serviceAccountTokenStore).authenticate(any(), any()); + final CachingServiceAccountTokenStore authenticatingStore = randomFrom(fileServiceAccountTokenStore, indexServiceAccountTokenStore); + Stream.of(fileServiceAccountTokenStore, indexServiceAccountTokenStore).forEach(store -> { + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = + (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse( + new ServiceAccountTokenStore.StoreAuthenticationResult(store == authenticatingStore, store.getTokenSource())); + return null; + }).when(store).authenticate(any(), any()); + }); + final String nodeName = randomAlphaOfLengthBetween(3, 8); serviceAccountService.authenticateToken( new ServiceAccountToken(new ServiceAccountId("elastic", "fleet-server"), "token1", @@ -271,7 +303,8 @@ public void testTryAuthenticateBearerToken() throws ExecutionException, Interrup Map.of("_elastic_service_account", true), true), new Authentication.RealmRef(ServiceAccountSettings.REALM_NAME, ServiceAccountSettings.REALM_TYPE, nodeName), null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, - Map.of("_token_name", "token1", "_token_source", tokenSource.name().toLowerCase(Locale.ROOT)) + Map.of("_token_name", "token1", + "_token_source", authenticatingStore.getTokenSource().name().toLowerCase(Locale.ROOT)) ) )); } @@ -337,28 +370,46 @@ public void testAuthenticateWithToken() throws ExecutionException, InterruptedEx + token3.getAccountId().asPrincipal() + "] with token name [" + token3.getTokenName() + "]")); appender.assertAllExpectationsMatched(); + final TokenInfo.TokenSource tokenSource = randomFrom(TokenInfo.TokenSource.values()); + final CachingServiceAccountTokenStore store; + final CachingServiceAccountTokenStore otherStore; + if (tokenSource == TokenInfo.TokenSource.FILE) { + store = fileServiceAccountTokenStore; + otherStore = indexServiceAccountTokenStore; + } else { + store = indexServiceAccountTokenStore; + otherStore = fileServiceAccountTokenStore; + } + // Success based on credential store final ServiceAccountId accountId4 = new ServiceAccountId(ElasticServiceAccounts.NAMESPACE, "fleet-server"); final ServiceAccountToken token4 = new ServiceAccountToken(accountId4, randomAlphaOfLengthBetween(3, 8), secret); final ServiceAccountToken token5 = new ServiceAccountToken(accountId4, randomAlphaOfLengthBetween(3, 8), new SecureString(randomAlphaOfLength(20).toCharArray())); - final TokenInfo.TokenSource tokenSource = randomFrom(TokenInfo.TokenSource.values()); final String nodeName = randomAlphaOfLengthBetween(3, 8); doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; - listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(true, tokenSource)); + listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(true, store.getTokenSource())); + return null; + }).when(store).authenticate(eq(token4), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = + (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(false, store.getTokenSource())); return null; - }).when(serviceAccountTokenStore).authenticate(eq(token4), any()); + }).when(store).authenticate(eq(token5), any()); doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; - listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(false, tokenSource)); + listener.onResponse(new ServiceAccountTokenStore.StoreAuthenticationResult(false, otherStore.getTokenSource())); return null; - }).when(serviceAccountTokenStore).authenticate(eq(token5), any()); + }).when(otherStore).authenticate(any(), any()); final PlainActionFuture future4 = new PlainActionFuture<>(); serviceAccountService.authenticateToken(token4, nodeName, future4); @@ -432,6 +483,58 @@ ServiceAccountSettings.REALM_NAME, ServiceAccountSettings.REALM_TYPE, randomAlph "cannot load role for service account [" + username + "] - no such service account")); } + public void testCreateIndexTokenWillDelegate() { + final Authentication authentication = mock(Authentication.class); + final CreateServiceAccountTokenRequest request = mock(CreateServiceAccountTokenRequest.class); + final ActionListener future = new PlainActionFuture<>(); + serviceAccountService.createIndexToken(authentication, request, future); + verify(indexServiceAccountTokenStore).createToken(eq(authentication), eq(request), eq(future)); + } + + public void testDeleteIndexTokenWillDelegate() { + final DeleteServiceAccountTokenRequest request = mock(DeleteServiceAccountTokenRequest.class); + final PlainActionFuture future = new PlainActionFuture<>(); + serviceAccountService.deleteIndexToken(request, future); + verify(indexServiceAccountTokenStore).deleteToken(eq(request), eq(future)); + } + + public void testFindTokensFor() { + final String namespace = randomAlphaOfLengthBetween(3, 8); + final String serviceName = randomAlphaOfLengthBetween(3, 8); + final ServiceAccountId accountId = new ServiceAccountId(namespace, serviceName); + + final List indexTokenInfos = IntStream.range(0, randomIntBetween(0, 3)) + .mapToObj(i -> TokenInfo.indexToken(ValidationTests.randomTokenName())) + .sorted() + .collect(Collectors.toUnmodifiableList()); + + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + @SuppressWarnings("unchecked") + final ActionListener> listener = (ActionListener>) args[1]; + listener.onResponse(indexTokenInfos); + return null; + }).when(indexServiceAccountTokenStore).findTokensFor(eq(accountId), any()); + + final GetServiceAccountCredentialsNodesResponse fileTokensResponse = mock(GetServiceAccountCredentialsNodesResponse.class); + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + @SuppressWarnings("unchecked") + final ActionListener listener = + (ActionListener) args[2]; + listener.onResponse(fileTokensResponse); + return null; + }).when(client).execute(eq(GetServiceAccountNodesCredentialsAction.INSTANCE), + any(GetServiceAccountCredentialsNodesRequest.class), any()); + + final PlainActionFuture future = new PlainActionFuture<>(); + serviceAccountService.findTokensFor(new GetServiceAccountCredentialsRequest(namespace, serviceName), future); + final GetServiceAccountCredentialsResponse response = future.actionGet(); + assertThat(response.getPrincipal(), equalTo(accountId.asPrincipal())); + assertThat(response.getNodesResponse(), is(fileTokensResponse)); + assertThat(response.getIndexTokenInfos(), equalTo(indexTokenInfos)); + } + public void testTlsRequired() { final Settings settings = Settings.builder() .put("xpack.security.http.ssl.enabled", false) @@ -440,8 +543,8 @@ public void testTlsRequired() { when(transport.boundAddress()).thenReturn( new BoundTransportAddress(new TransportAddress[] { transportAddress }, transportAddress)); - final ServiceAccountService service = new ServiceAccountService( - serviceAccountTokenStore, + final ServiceAccountService service = new ServiceAccountService(client, + fileServiceAccountTokenStore,indexServiceAccountTokenStore, new HttpTlsRuntimeCheck(settings, new SetOnce<>(transport))); final PlainActionFuture future1 = new PlainActionFuture<>(); @@ -462,6 +565,21 @@ public void testTlsRequired() { 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")); + + final PlainActionFuture future3 = new PlainActionFuture<>(); + service.createIndexToken(authentication, mock(CreateServiceAccountTokenRequest.class), future3); + final ElasticsearchException e3 = expectThrows(ElasticsearchException.class, future3::actionGet); + assertThat(e3.getMessage(), containsString("[create index-backed service token] requires TLS for the HTTP interface")); + + final PlainActionFuture future4 = new PlainActionFuture<>(); + service.deleteIndexToken(mock(DeleteServiceAccountTokenRequest.class), future4); + final ElasticsearchException e4 = expectThrows(ElasticsearchException.class, future4::actionGet); + assertThat(e4.getMessage(), containsString("[delete index-backed service token] requires TLS for the HTTP interface")); + + final PlainActionFuture future5 = new PlainActionFuture<>(); + service.findTokensFor(mock(GetServiceAccountCredentialsRequest.class), future5); + final ElasticsearchException e5 = expectThrows(ElasticsearchException.class, future5::actionGet); + assertThat(e5.getMessage(), containsString("[find service tokens] requires TLS for the HTTP interface")); } private SecureString createBearerString(List bytesList) throws IOException { diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/service_accounts/10_basic.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/service_accounts/10_basic.yml index bc034be9f088a..31dc539e11bfe 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/service_accounts/10_basic.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/service_accounts/10_basic.yml @@ -76,7 +76,10 @@ teardown: - match: { "service_account": "elastic/fleet-server" } - match: { "count": 2 } - match: { "tokens": { "api-token-1": {} } } - - match: { "file_tokens": { "token1": {}} } + - match: { "nodes_credentials._nodes.failed": 0 } + - is_true: nodes_credentials.file_tokens.token1 + - is_true: nodes_credentials.file_tokens.token1.nodes + - match: { "nodes_credentials.file_tokens.token1.nodes.0" : "/(yamlRestTest-0|yamlRestCompatTest-0)/" } - do: security.clear_cached_service_tokens: