Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import org.elasticsearch.client.Validatable;
import org.elasticsearch.client.security.user.privileges.Role;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;
Expand All @@ -47,12 +46,9 @@ public final class CreateApiKeyRequest implements Validatable, ToXContentObject
* @param roles list of {@link Role}s
* @param expiration to specify expiration for the API key
*/
public CreateApiKeyRequest(String name, List<Role> roles, @Nullable TimeValue expiration, @Nullable final RefreshPolicy refreshPolicy) {
if (Strings.hasText(name)) {
this.name = name;
} else {
throw new IllegalArgumentException("name must not be null or empty");
}
public CreateApiKeyRequest(@Nullable String name, List<Role> roles, @Nullable TimeValue expiration,
@Nullable final RefreshPolicy refreshPolicy) {
this.name = name;
this.roles = Objects.requireNonNull(roles, "roles may not be null");
this.expiration = expiration;
this.refreshPolicy = (refreshPolicy == null) ? RefreshPolicy.getDefault() : refreshPolicy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.ObjectParser;
import org.elasticsearch.common.xcontent.XContentParser;

import java.io.IOException;
Expand Down Expand Up @@ -131,7 +132,8 @@ public boolean equals(Object obj) {
(args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6]);
});
static {
PARSER.declareString(constructorArg(), new ParseField("name"));
PARSER.declareField(optionalConstructorArg(), (p, c) -> p.textOrNull(), new ParseField("name"),
ObjectParser.ValueType.STRING_OR_NULL);
PARSER.declareString(constructorArg(), new ParseField("id"));
PARSER.declareLong(constructorArg(), new ParseField("creation"));
PARSER.declareLong(optionalConstructorArg(), new ParseField("expiration"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public void testFromXContent() throws IOException {
"user-a", "realm-x");
ApiKey apiKeyInfo2 = createApiKeyInfo("name2", "id-2", Instant.ofEpochMilli(100000L), Instant.ofEpochMilli(10000000L), true,
"user-b", "realm-y");
GetApiKeyResponse response = new GetApiKeyResponse(Arrays.asList(apiKeyInfo1, apiKeyInfo2));
ApiKey apiKeyInfo3 = createApiKeyInfo(null, "id-3", Instant.ofEpochMilli(100000L), null, true,
"user-c", "realm-z");
GetApiKeyResponse response = new GetApiKeyResponse(Arrays.asList(apiKeyInfo1, apiKeyInfo2, apiKeyInfo3));
final XContentType xContentType = randomFrom(XContentType.values());
final XContentBuilder builder = XContentFactory.contentBuilder(xContentType);
toXContent(response, builder);
Expand Down
6 changes: 3 additions & 3 deletions docs/java-rest/high-level/security/create-api-key.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ API Key can be created using this API.
[id="{upid}-{api}-request"]
==== Create API Key Request

A +{request}+ contains name for the API key,
list of role descriptors to define permissions and
A +{request}+ contains an optional name for the API key,
an optional list of role descriptors to define permissions and
optional expiration for the generated API key.
If expiration is not provided then by default the API
keys do not expire.
Expand All @@ -37,4 +37,4 @@ expiration.
include-tagged::{doc-tests-file}[{api}-response]
--------------------------------------------------
<1> the API key that can be used to authenticate to Elasticsearch.
<2> expiration if the API keys expire
<2> expiration if the API keys expire
2 changes: 1 addition & 1 deletion x-pack/docs/en/rest-api/security/create-api-keys.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ service.
The following parameters can be specified in the body of a POST or PUT request:

`name`::
(string) Specifies the name for this API key.
(Optional, string) Specifies the name for this API key.

`role_descriptors`::
(Optional, array-of-role-descriptor) An array of role descriptors for this API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

package org.elasticsearch.xpack.core.security.action;

import org.elasticsearch.Version;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
Expand Down Expand Up @@ -49,7 +50,11 @@ public ApiKey(String name, String id, Instant creation, Instant expiration, bool
}

public ApiKey(StreamInput in) throws IOException {
this.name = in.readString();
if (in.getVersion().onOrAfter(Version.V_7_5_0)) {
this.name = in.readOptionalString();
} else {
this.name = in.readString();
}
this.id = in.readString();
this.creation = in.readInstant();
this.expiration = in.readOptionalInstant();
Expand Down Expand Up @@ -103,7 +108,11 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(name);
if (out.getVersion().onOrAfter(Version.V_7_5_0)) {
out.writeOptionalString(name);
} else {
out.writeString(name);
}
out.writeString(id);
out.writeInstant(creation);
out.writeOptionalInstant(expiration);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

package org.elasticsearch.xpack.core.security.action;

import org.elasticsearch.Version;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.action.support.WriteRequest;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.unit.TimeValue;
Expand Down Expand Up @@ -43,19 +43,19 @@ public CreateApiKeyRequest() {}
* @param roleDescriptors list of {@link RoleDescriptor}s
* @param expiration to specify expiration for the API key
*/
public CreateApiKeyRequest(String name, @Nullable List<RoleDescriptor> roleDescriptors, @Nullable TimeValue expiration) {
if (Strings.hasText(name)) {
this.name = name;
} else {
throw new IllegalArgumentException("name must not be null or empty");
}
public CreateApiKeyRequest(@Nullable String name, @Nullable List<RoleDescriptor> roleDescriptors, @Nullable TimeValue expiration) {
this.name = name;
this.roleDescriptors = (roleDescriptors == null) ? List.of() : List.copyOf(roleDescriptors);
this.expiration = expiration;
}

public CreateApiKeyRequest(StreamInput in) throws IOException {
super(in);
this.name = in.readString();
if (in.getVersion().onOrAfter(Version.V_7_5_0)) {
this.name = in.readOptionalString();
} else {
this.name = in.readString();
}
this.expiration = in.readOptionalTimeValue();
this.roleDescriptors = List.copyOf(in.readList(RoleDescriptor::new));
this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in);
Expand All @@ -65,12 +65,8 @@ public String getName() {
return name;
}

public void setName(String name) {
if (Strings.hasText(name)) {
this.name = name;
} else {
throw new IllegalArgumentException("name must not be null or empty");
}
public void setName(@Nullable String name) {
this.name = name;
}

public TimeValue getExpiration() {
Expand Down Expand Up @@ -100,9 +96,7 @@ public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) {
@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = null;
if (Strings.isNullOrEmpty(name)) {
validationException = addValidationError("name is required", validationException);
} else {
if (name != null) {
if (name.length() > 256) {
validationException = addValidationError("name may not be more than 256 characters long", validationException);
}
Expand All @@ -119,7 +113,11 @@ public ActionRequestValidationException validate() {
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(name);
if (out.getVersion().onOrAfter(Version.V_7_5_0)) {
out.writeOptionalString(name);
} else {
out.writeString(name);
}
out.writeOptionalTimeValue(expiration);
out.writeList(roleDescriptors);
refreshPolicy.writeTo(out);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,12 @@ public void testNameValidation() {
CreateApiKeyRequest request = new CreateApiKeyRequest();

ActionRequestValidationException ve = request.validate();
assertNotNull(ve);
assertThat(ve.validationErrors().size(), is(1));
assertThat(ve.validationErrors().get(0), containsString("name is required"));
assertNull(ve);

request.setName(name);
ve = request.validate();
assertNull(ve);

IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> request.setName(""));
assertThat(e.getMessage(), containsString("name must not be null or empty"));

e = expectThrows(IllegalArgumentException.class, () -> request.setName(null));
assertThat(e.getMessage(), containsString("name must not be null or empty"));

request.setName(randomAlphaOfLength(257));
ve = request.validate();
assertNotNull(ve);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
public class GetApiKeyResponseTests extends ESTestCase {

public void testSerialization() throws IOException {
boolean withApiKeyName = randomBoolean();
boolean withExpiration = randomBoolean();
ApiKey apiKeyInfo = createApiKeyInfo(randomAlphaOfLength(4), randomAlphaOfLength(5), Instant.now(),
ApiKey apiKeyInfo = createApiKeyInfo((withApiKeyName) ? randomAlphaOfLength(4) : null, randomAlphaOfLength(5), Instant.now(),
(withExpiration) ? Instant.now() : null, false, randomAlphaOfLength(4), randomAlphaOfLength(5));
GetApiKeyResponse response = new GetApiKeyResponse(Collections.singletonList(apiKeyInfo));
try (BytesStreamOutput output = new BytesStreamOutput()) {
Expand All @@ -42,7 +43,9 @@ public void testToXContent() throws IOException {
"user-a", "realm-x");
ApiKey apiKeyInfo2 = createApiKeyInfo("name2", "id-2", Instant.ofEpochMilli(100000L), Instant.ofEpochMilli(10000000L), true,
"user-b", "realm-y");
GetApiKeyResponse response = new GetApiKeyResponse(Arrays.asList(apiKeyInfo1, apiKeyInfo2));
ApiKey apiKeyInfo3 = createApiKeyInfo(null, "id-3", Instant.ofEpochMilli(100000L), null, true,
"user-c", "realm-z");
GetApiKeyResponse response = new GetApiKeyResponse(Arrays.asList(apiKeyInfo1, apiKeyInfo2, apiKeyInfo3));
XContentBuilder builder = XContentFactory.jsonBuilder();
response.toXContent(builder, ToXContent.EMPTY_PARAMS);
assertThat(Strings.toString(builder), equalTo(
Expand All @@ -51,7 +54,9 @@ public void testToXContent() throws IOException {
+ "{\"id\":\"id-1\",\"name\":\"name1\",\"creation\":100000,\"expiration\":10000000,\"invalidated\":false,"
+ "\"username\":\"user-a\",\"realm\":\"realm-x\"},"
+ "{\"id\":\"id-2\",\"name\":\"name2\",\"creation\":100000,\"expiration\":10000000,\"invalidated\":true,"
+ "\"username\":\"user-b\",\"realm\":\"realm-y\"}"
+ "\"username\":\"user-b\",\"realm\":\"realm-y\"},"
+ "{\"id\":\"id-3\",\"name\":null,\"creation\":100000,\"invalidated\":true,"
+ "\"username\":\"user-c\",\"realm\":\"realm-z\"}"
+ "]"
+ "}"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.index.IndexAction;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.search.SearchAction;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
import org.elasticsearch.action.update.UpdateRequest;
Expand Down Expand Up @@ -193,46 +192,10 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ
if (authentication == null) {
listener.onFailure(new IllegalArgumentException("authentication must be provided"));
} else {
/*
* Check if requested API key name already exists to avoid duplicate key names,
* this check is best effort as there could be two nodes executing search and
* then index concurrently allowing a duplicate name.
*/
checkDuplicateApiKeyNameAndCreateApiKey(authentication, request, userRoles, listener);
createApiKeyAndIndexIt(authentication, request, userRoles, listener);
}
}

private void checkDuplicateApiKeyNameAndCreateApiKey(Authentication authentication, CreateApiKeyRequest request,
Set<RoleDescriptor> userRoles,
ActionListener<CreateApiKeyResponse> listener) {
final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("doc_type", "api_key"))
.filter(QueryBuilders.termQuery("name", request.getName()))
.filter(QueryBuilders.termQuery("api_key_invalidated", false));
final BoolQueryBuilder expiredQuery = QueryBuilders.boolQuery()
.should(QueryBuilders.rangeQuery("expiration_time").lte(Instant.now().toEpochMilli()))
.should(QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery("expiration_time")));
boolQuery.filter(expiredQuery);

final SearchRequest searchRequest = client.prepareSearch(SECURITY_MAIN_ALIAS)
.setQuery(boolQuery)
.setVersion(false)
.setSize(1)
.request();
securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
executeAsyncWithOrigin(client, SECURITY_ORIGIN, SearchAction.INSTANCE, searchRequest,
ActionListener.wrap(
indexResponse -> {
if (indexResponse.getHits().getTotalHits().value > 0) {
listener.onFailure(traceLog("create api key", new ElasticsearchSecurityException(
"Error creating api key as api key with name [{}] already exists", request.getName())));
} else {
createApiKeyAndIndexIt(authentication, request, userRoles, listener);
}
},
listener::onFailure)));
}

private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyRequest request, Set<RoleDescriptor> roleDescriptorSet,
ActionListener<CreateApiKeyResponse> listener) {
final Instant created = clock.instant();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
package org.elasticsearch.xpack.security.authc;

import com.google.common.collect.Sets;

import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.DocWriteResponse;
import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse;
Expand Down Expand Up @@ -175,43 +174,24 @@ public void testCreateApiKey() {
assertThat(e.status(), is(RestStatus.FORBIDDEN));
}

public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() throws InterruptedException, ExecutionException {
public void testMultipleApiKeysCanHaveSameName() {
String keyName = randomAlphaOfLength(5);
int noOfApiKeys = randomIntBetween(2, 5);
List<CreateApiKeyResponse> responses = new ArrayList<>();
{
final RoleDescriptor descriptor = new RoleDescriptor("role", new String[] { "monitor" }, null, null);
for (int i = 0; i < noOfApiKeys; i++) {
final RoleDescriptor descriptor = new RoleDescriptor("role", new String[]{"monitor"}, null, null);
Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken
.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)));
.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)));
final CreateApiKeyResponse response = new CreateApiKeyRequestBuilder(client).setName(keyName).setExpiration(null)
.setRoleDescriptors(Collections.singletonList(descriptor)).get();
.setRoleDescriptors(Collections.singletonList(descriptor)).get();
assertNotNull(response.getId());
assertNotNull(response.getKey());
responses.add(response);
}

final RoleDescriptor descriptor = new RoleDescriptor("role", new String[] { "monitor" }, null, null);
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER,
SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)));
ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> new CreateApiKeyRequestBuilder(client)
.setName(keyName)
.setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L)))
.setRoleDescriptors(Collections.singletonList(descriptor))
.get());
assertThat(e.getMessage(), equalTo("Error creating api key as api key with name ["+keyName+"] already exists"));

// Now invalidate the API key
PlainActionFuture<InvalidateApiKeyResponse> listener = new PlainActionFuture<>();
client.execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.usingApiKeyName(keyName, false), listener);
InvalidateApiKeyResponse invalidateResponse = listener.get();
verifyInvalidateResponse(1, responses, invalidateResponse);

// try to create API key with same name, should succeed now
CreateApiKeyResponse createResponse = new CreateApiKeyRequestBuilder(client).setName(keyName)
.setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L)))
.setRoleDescriptors(Collections.singletonList(descriptor)).get();
assertNotNull(createResponse.getId());
assertNotNull(createResponse.getKey());
assertThat(responses.size(), is(noOfApiKeys));
for (int i = 0; i < noOfApiKeys; i++) {
assertThat(responses.get(i).getName(), is(keyName));
}
}

public void testInvalidateApiKeysForRealm() throws InterruptedException, ExecutionException {
Expand Down