Skip to content

Conversation

@BigPandaToo
Copy link
Contributor

API can be called by the startup process or a user with appropriate
privileges while elasticsearch is in the enrollment mode to obtain an
enrollment token used to enroll a new node to the cluster or configure a
new client to communicate with the cluster.

Resolve: #71438
Related: #72129

As part of the security on be
default project, we will be offering an enrollment process that allows
new nodes to join a cluster, or clients to bootstrap their configuration
to communicate with a cluster that is already running with security
enabled. This enrollment process is based on the use of enrollment
tokens.
API can be called by the startup process or a user with appropriate
privileges while elasticsearch is in the enrollment mode to obtain an
enrollment token used to enroll a new node to the cluster or configure a
new client to communicate with the cluster.

Resolve: elastic#71438
Related: elastic#72129
@BigPandaToo BigPandaToo added :Security/Security Security issues without another label >enhancement Team:Security Meta label for security team v8.0.0 labels Apr 23, 2021
@elasticmachine
Copy link
Collaborator

Pinging @elastic/es-security (Team:Security)

Copy link
Contributor

@jkakavas jkakavas left a comment

Choose a reason for hiding this comment

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

Added some comments, will take another look on Monday too

@BigPandaToo
Copy link
Contributor Author

@elasticmachine update branch

@BigPandaToo BigPandaToo requested a review from jkakavas April 26, 2021 19:52
@BigPandaToo
Copy link
Contributor Author

@jkakavas Refactored the code according to our discussion.
The PR still pending:

  • Transport Action test
  • Some Doc tests are still failing
  • Rest high level client API (may be a separate PR)

@ywangd ywangd mentioned this pull request Apr 27, 2021
@BigPandaToo
Copy link
Contributor Author

@elasticmachine update branch

builder.endObject();
final String jsonString = Strings.toString(builder);
final String token = Base64.getEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8));

Copy link
Contributor

Choose a reason for hiding this comment

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

maybe log the enrollment token pre-encoding in TRACE level ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if we should log this material...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@albertzaharovits what's your take on this?

Copy link
Contributor

Choose a reason for hiding this comment

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

We usually do not log credentials. In this case they are short lived, but on the other hand they are powerful (add a new node). I would not log it.

@BigPandaToo BigPandaToo requested a review from jkakavas May 5, 2021 19:39
builder.endObject();
final String jsonString = Strings.toString(builder);
final String token = Base64.getEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8));

Copy link
Contributor

Choose a reason for hiding this comment

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

@jkakavas
Copy link
Contributor

jkakavas commented May 12, 2021 via email

@BigPandaToo BigPandaToo requested a review from jkakavas May 12, 2021 19:55
@BigPandaToo
Copy link
Contributor Author

@elasticmachine update branch

@BigPandaToo BigPandaToo requested a review from ywangd May 17, 2021 12:17
Copy link
Contributor

@albertzaharovits albertzaharovits left a comment

Choose a reason for hiding this comment

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

I did a very comprehensive review. It is looking good, yet I was a bit opinionated about some parts of it. This API is crucial and sensitive, so it's something we need to take extra care with, IMO.

But this needs tests, most notably integration tests (eg see ApiKeyIntegTests, and maybe SecurityDocumentationIT as Ioannis was saying).

EDIT:
I would also propose we "shape" this more for node enrollment. It is likely we'll not give kibana a token that can be used to take over the cluster (ie the node enrollment token), but rather a different token. I won't dwell much on this, we can rectify names and docs later.

EDIT2:
We need to decide if we want to expose this enroll API to "unenrolled" clients (this PR contains code for it). I am of the opinion that we should not, because otherwise we have to think about a flow for the client to get the credentials and the cert; ie it is not sufficient to say "use these superuser credentials" for two reasons:

  • the client cannot validate the TLS connection (in a sense, it can't because it is not enrolled), which makes it vulnerable to a MITM that can intercept the token.
  • if the superuser credentials can now add new nodes and generate HTTPS node certificates, it changes the threat model, eg superusers can be easily disabled, but a node participation to a cluster and associated HTTP TLS certificates cannot be revoked (at least it is very hard).

public List<Route> routes() {
return List.of(
new Route(POST, "/_security/enrollment_token"),
new Route(PUT, "/_security/enrollment_token"));
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: In general I prefer we don't use PUT without a {name} URL part, but I know we're not consistent.
If you're also opinionated about this and don't like the PUT but copied it for consistency, we can remove it.


@Override
public String getName() {
return "cluster_enrolment_token_action";
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return "cluster_enrolment_token_action";
return "security_create_enrollment_token_action";

/**
* Rest endpoint to create an enrollment token
*/
public class RestCreateEnrollmentTokenAction extends SecurityBaseRestHandler {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: make it final.

@Override
protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
if (Node.ENROLLMENT_ENABLED.get(settings) != true) {
throw new IllegalStateException("Enrollment mode is not enabled.");
Copy link
Contributor

Choose a reason for hiding this comment

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

IllegalStateExceptions are a bit scary and they look unpolished IMO.
I tend to use them like an assert deep inside the internals. It's true that this API can be thought off as internal, but a little user friendliness can't hurt either, who knows how we might expose it.
My suggestion is to use an ElasticsearchException for this case, with a more detailed message, such as: "This node is not configured to issue enrollment tokens which allow clients and new nodes to communicate and join the cluster"
Also, this translates to HTTP 500, which is OKish, but it signals that the server hit an error when it actually followed the configuration. So in the spirit of improving user friendliness, I would opt for one of 501, 403 or 404 (see RestStatus).

Copy link
Member

Choose a reason for hiding this comment

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

I think status code 4xx is more suitable in this scenario than 5xx. As I understand it, we should use 4xx to signal client error which can be rectified by the cilent (either change request parameters or in this case enable enrollment first). I think 400 is suitable for this case even though it is generic. Because this failure is not really about permission (403) or resource being unavailable (404).

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks @ywangd , 400 looks good to me too.


<1> The enrollment token contains the following information:
- IP Address and port number for the interface where the Elasticsearch node is listening for HTTP connections;
- The fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS on the HTTP layer;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: mention that this is the SHA-1 fingerprint of the DER encoding for the certificate.

ActionListener.wrap(
CreateApiKeyResponse -> {
try {
final String address = nodeService.info(false, false, false, false, false, false,
Copy link
Contributor

Choose a reason for hiding this comment

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

We should use the same output format that nodes info uses, eg NetworkAddress.format(httpInfo.getAddress().publishAddress() .

return;
} else {
final TimeValue expiration = TimeValue.timeValueSeconds(ENROLL_API_KEY_EXPIRATION_SEC);
final String[] clusterPrivileges = { "enroll" };
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use ClusterPrivilegeResolver.ENROLL.getName().

Copy link
Member

@ywangd ywangd left a comment

Choose a reason for hiding this comment

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

I stil need look closer to the client code and tests. Thanks!

"security.create_enrollment_token":{
"documentation":{
"url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/security-create-enrollment-token.html",
"description":"Create an enrollment token to allow a new node to enroll in an existing secured elasticsearch cluster, or a client to configure itself to communicate with a secured elasticsearch cluster."
Copy link
Member

Choose a reason for hiding this comment

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

Based on the live discussion, we can remove the "enroll client" bit here for now.

"url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/security-create-enrollment-token.html",
"description":"Create an enrollment token to allow a new node to enroll in an existing secured elasticsearch cluster, or a client to configure itself to communicate with a secured elasticsearch cluster."
},
"stability":"stable",
Copy link
Member

Choose a reason for hiding this comment

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

Maybe this should be "experimental"?

==== {api-description-title}

The purpose of the create enrollment token API is to generate an enrollment with which user can enroll a new node
in an existing secured elasticsearch cluster, or a client can configure itself to
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, we can remove "enroll client" here.


@Override
public String toString() {
return "get_enrollment_token";
Copy link
Member

Choose a reason for hiding this comment

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

Nit: It is not really necessary to override this method since the request class does not have any field itself and the super class's implementation should be sufficient. But if we are overriding, it's better to have it consistent with the class name, i.e. create_... instead of get_...?

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class CreateEnrollmentTokenResponseTests extends AbstractXContentTestCase<CreateEnrollmentTokenResponse> {
Copy link
Member

Choose a reason for hiding this comment

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

The AbstractXContentTestCase is mostly and makes more sense for testing the client side CreateEnrollmentTokenResponse class, where you already have a parser. And you test the client side response class can correctly parse the server side response. For test server side response itself, you can extend AbstractWireSerializingTestCase. But we are not consistent about it.

builder.startObject();
builder.field("adr", address);
builder.field("fgr", fingerprint);
builder.field("key", CreateApiKeyResponse.getKey().toString());
Copy link
Member

Choose a reason for hiding this comment

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

This is only the "secret" part of an API key, you need the ID part to be returned as part of the response as well.

Comment on lines +68 to +76
new RestBuilderListener<>(channel) {
@Override
public RestResponse buildResponse(CreateEnrollmentTokenResponse response, XContentBuilder builder) throws Exception {
builder.startObject();
builder.field("enrollment_token", response.getEnrollmentToken());
builder.endObject();
return new BytesRestResponse(RestStatus.OK, builder);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
new RestBuilderListener<>(channel) {
@Override
public RestResponse buildResponse(CreateEnrollmentTokenResponse response, XContentBuilder builder) throws Exception {
builder.startObject();
builder.field("enrollment_token", response.getEnrollmentToken());
builder.endObject();
return new BytesRestResponse(RestStatus.OK, builder);
}
}
new RestToXContentListener<>(channel)

---
"Test get builtin privileges 7.x":
- skip:
version: "7.99.99 - "
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
version: "7.99.99 - "
version: "8.0.0 - "

generator.generateApiKey(securityContext.getAuthentication(), apiRequest,
ActionListener.wrap(
CreateApiKeyResponse -> {
try {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: this is not required, the exception is caught in ActionListener#doExecute.

@albertzaharovits
Copy link
Contributor

After further consideration this PR seems tricky because of the design change discussions that are in progress.

Summarizing the design discussions that are in-progress:

  • client enrollment needs further refinement, because whoever is going to eg install kibana to connect to ES, should also not be able to add new ES nodes
  • the API to generate an enrollment token should not have a client interface, as it can be used to extract the secret key material from nodes. We can add it later, but only if absolutely necessary, not just in case. I would go so for as recommending against creating an API in the first place and piecing together the token in the cmd line utility (or the start-up script) that has to run locally.
  • the manage_enrollment and enroll privileges are effectively the same privilege, ie the privilege to take over the cluster (like a superuser you cannot disable - more than a superuser).
  • the enrollment token is opaque but it doesn't have to. I think there are benefits for it not being opaque, ie a comma delimited list, chief among them is that the admin can tinker with the hostname and port parts, to account for any proxy in between the client and the node. It would also be shorter because encoding text to base64 expands the number of characters.

@albertzaharovits
Copy link
Contributor

albertzaharovits commented May 27, 2021

@BigPandaToo As we've discussed, I'm going to approve this PR, even if it's not addressing all the points above, considering that it is following the original design. But I will raise blocker issues that we'll need to sort out before we release.

Please resolve all the comments by either fixing or saying that the respective point needs to be discussed further (and fixed in a follow-up).
I'll take another quick look afterwards, and approve.

@BigPandaToo
Copy link
Contributor Author

@elasticmachine update branch

@Override
public List<Route> routes() {
return List.of(
new Route(POST, "/_security/enrollment_token"),
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is what the design doc proposes, but I think it's a mistake.
I think all the enrollment APIs should be under a common URI path.

That is the doc proposes:

GET /_security/enroll_node
POST /_security/enroll_client
POST /_security/enrollment_token

Putting aside the HTTP Method question, think the lack of a common prefix is a problem.
I would propose that everything is under /_security/enroll/ or /_security/enrollment/

e.g.

POST /_security/enroll/node
POST /_security/enroll/client
POST /_security/enroll/token

Copy link
Contributor

Choose a reason for hiding this comment

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

❤️ /_security/enrollment

@BigPandaToo
Copy link
Contributor Author

A new one is posted instead: #73573

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

>enhancement :Security/Security Security issues without another label Team:Security Meta label for security team v8.0.0-alpha1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement create enrollment token API

7 participants