From f087b547eac3950df0de2208bcb504bc5e02e40a Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Fri, 2 Nov 2018 07:48:18 -0600 Subject: [PATCH 01/26] Security: add create api key transport action (#34572) In order to support api keys for access to elasticsearch, we need the ability to generate these api keys. A transport action has been added along with the request and response objects that allow for the generation of api keys. The api keys require a name and optionally allow a role to be specified which defines the amount of access the key has. Additionally an expiration may also be provided. This change does not include the restriction that the role needs to be a subset of the user's permissions, which will be added seperately. As it exists in this change, the api key is currently not usable which is another aspect that will come later. Relates #34383 --- .../common/RandomBasedUUIDGenerator.java | 34 +++- .../java/org/elasticsearch/common/UUIDs.java | 7 + .../common/io/stream/StreamInput.java | 17 ++ .../common/io/stream/StreamOutput.java | 21 +++ .../common/io/stream/StreamTests.java | 32 ++++ .../xpack/core/XPackClientPlugin.java | 2 + .../xpack/core/XPackSettings.java | 7 +- .../security/action/CreateApiKeyAction.java | 33 ++++ .../security/action/CreateApiKeyRequest.java | 114 +++++++++++++ .../action/CreateApiKeyRequestBuilder.java | 44 +++++ .../security/action/CreateApiKeyResponse.java | 91 ++++++++++ .../action/role/GetRolesResponse.java | 4 +- .../core/security/authz/RoleDescriptor.java | 71 ++++---- .../core/security/client/SecurityClient.java | 12 ++ .../resources/security-index-template.json | 27 +++ .../action/CreateApiKeyRequestTests.java | 113 +++++++++++++ .../action/CreateApiKeyResponseTests.java | 40 +++++ .../xpack/security/Security.java | 10 +- .../action/TransportCreateApiKeyAction.java | 62 +++++++ .../xpack/security/authc/ApiKeyService.java | 158 ++++++++++++++++++ .../xpack/security/authc/TokenService.java | 3 +- .../authz/store/CompositeRolesStore.java | 11 ++ .../security/authc/ApiKeyIntegTests.java | 80 +++++++++ .../security/authz/RoleDescriptorTests.java | 8 +- 24 files changed, 951 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java diff --git a/server/src/main/java/org/elasticsearch/common/RandomBasedUUIDGenerator.java b/server/src/main/java/org/elasticsearch/common/RandomBasedUUIDGenerator.java index 59e5960b99d09..b5b35b477efbd 100644 --- a/server/src/main/java/org/elasticsearch/common/RandomBasedUUIDGenerator.java +++ b/server/src/main/java/org/elasticsearch/common/RandomBasedUUIDGenerator.java @@ -20,6 +20,9 @@ package org.elasticsearch.common; +import org.elasticsearch.common.settings.SecureString; + +import java.util.Arrays; import java.util.Base64; import java.util.Random; @@ -34,12 +37,37 @@ public String getBase64UUID() { return getBase64UUID(SecureRandomHolder.INSTANCE); } + /** + * Returns a Base64 encoded {@link SecureString} of a Version 4.0 compatible UUID + * as defined here: http://www.ietf.org/rfc/rfc4122.txt + */ + public SecureString getBase64UUIDSecureString() { + byte[] uuidBytes = null; + byte[] encodedBytes = null; + try { + uuidBytes = getUUIDBytes(SecureRandomHolder.INSTANCE); + encodedBytes = Base64.getUrlEncoder().withoutPadding().encode(uuidBytes); + return new SecureString(CharArrays.utf8BytesToChars(encodedBytes)); + } finally { + if (uuidBytes != null) { + Arrays.fill(uuidBytes, (byte) 0); + } + if (encodedBytes != null) { + Arrays.fill(encodedBytes, (byte) 0); + } + } + } + /** * Returns a Base64 encoded version of a Version 4.0 compatible UUID * randomly initialized by the given {@link java.util.Random} instance * as defined here: http://www.ietf.org/rfc/rfc4122.txt */ public String getBase64UUID(Random random) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(getUUIDBytes(random)); + } + + private byte[] getUUIDBytes(Random random) { final byte[] randomBytes = new byte[16]; random.nextBytes(randomBytes); /* Set the version to version 4 (see http://www.ietf.org/rfc/rfc4122.txt) @@ -48,12 +76,12 @@ public String getBase64UUID(Random random) { * stamp (bits 4 through 7 of the time_hi_and_version field).*/ randomBytes[6] &= 0x0f; /* clear the 4 most significant bits for the version */ randomBytes[6] |= 0x40; /* set the version to 0100 / 0x40 */ - - /* Set the variant: + + /* Set the variant: * The high field of th clock sequence multiplexed with the variant. * We set only the MSB of the variant*/ randomBytes[8] &= 0x3f; /* clear the 2 most significant bits */ randomBytes[8] |= 0x80; /* set the variant (MSB is set)*/ - return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + return randomBytes; } } diff --git a/server/src/main/java/org/elasticsearch/common/UUIDs.java b/server/src/main/java/org/elasticsearch/common/UUIDs.java index 63fcaedde0f5c..a6a314c2cccb0 100644 --- a/server/src/main/java/org/elasticsearch/common/UUIDs.java +++ b/server/src/main/java/org/elasticsearch/common/UUIDs.java @@ -19,6 +19,8 @@ package org.elasticsearch.common; +import org.elasticsearch.common.settings.SecureString; + import java.util.Random; public class UUIDs { @@ -50,4 +52,9 @@ public static String randomBase64UUID() { return RANDOM_UUID_GENERATOR.getBase64UUID(); } + /** Returns a Base64 encoded {@link SecureString} of a Version 4.0 compatible UUID as defined here: http://www.ietf.org/rfc/rfc4122.txt, + * using a private {@code SecureRandom} instance */ + public static SecureString randomBase64UUIDSecureString() { + return RANDOM_UUID_GENERATOR.getBase64UUIDSecureString(); + } } diff --git a/server/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java b/server/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java index fd9ffdfd31d16..299ea512a40e1 100644 --- a/server/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java +++ b/server/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java @@ -578,6 +578,23 @@ public Object readGenericValue() throws IOException { } } + /** + * Read an {@link Instant} from the stream with nanosecond resolution + */ + public final Instant readInstant() throws IOException { + return Instant.ofEpochSecond(readLong(), readInt()); + } + + /** + * Read an optional {@link Instant} from the stream. Returns null when + * no instant is present. + */ + @Nullable + public final Instant readOptionalInstant() throws IOException { + final boolean present = readBoolean(); + return present ? readInstant() : null; + } + @SuppressWarnings("unchecked") private List readArrayList() throws IOException { int size = readArraySize(); diff --git a/server/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java b/server/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java index b00706b78aedb..4c2cd7505bca5 100644 --- a/server/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java +++ b/server/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java @@ -55,6 +55,7 @@ import java.nio.file.FileSystemLoopException; import java.nio.file.NoSuchFileException; import java.nio.file.NotDirectoryException; +import java.time.Instant; import java.time.ZonedDateTime; import java.util.Collection; import java.util.Collections; @@ -560,6 +561,26 @@ public final void writeMap(final Map map, final Writer keyWriter } } + /** + * Writes an {@link Instant} to the stream with nanosecond resolution + */ + public final void writeInstant(Instant instant) throws IOException { + writeLong(instant.getEpochSecond()); + writeInt(instant.getNano()); + } + + /** + * Writes an {@link Instant} to the stream, which could possibly be null + */ + public final void writeOptionalInstant(@Nullable Instant instant) throws IOException { + if (instant == null) { + writeBoolean(false); + } else { + writeBoolean(true); + writeInstant(instant); + } + } + private static final Map, Writer> WRITERS; static { diff --git a/server/src/test/java/org/elasticsearch/common/io/stream/StreamTests.java b/server/src/test/java/org/elasticsearch/common/io/stream/StreamTests.java index 6431a3469b6b0..12634ea16011c 100644 --- a/server/src/test/java/org/elasticsearch/common/io/stream/StreamTests.java +++ b/server/src/test/java/org/elasticsearch/common/io/stream/StreamTests.java @@ -28,6 +28,7 @@ import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.IOException; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -248,6 +249,37 @@ public void testSetOfLongs() throws IOException { assertThat(targetSet, equalTo(sourceSet)); } + public void testInstantSerialization() throws IOException { + final Instant instant = Instant.now(); + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeInstant(instant); + try (StreamInput in = out.bytes().streamInput()) { + final Instant serialized = in.readInstant(); + assertEquals(instant, serialized); + } + } + } + + public void testOptionalInstantSerialization() throws IOException { + final Instant instant = Instant.now(); + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeOptionalInstant(instant); + try (StreamInput in = out.bytes().streamInput()) { + final Instant serialized = in.readOptionalInstant(); + assertEquals(instant, serialized); + } + } + + final Instant missing = null; + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeOptionalInstant(missing); + try (StreamInput in = out.bytes().streamInput()) { + final Instant serialized = in.readOptionalInstant(); + assertEquals(missing, serialized); + } + } + } + static final class WriteableString implements Writeable { final String string; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java index 21bd005ac5b7c..392179309ea62 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java @@ -112,6 +112,7 @@ import org.elasticsearch.xpack.core.security.SecurityFeatureSetUsage; import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction; import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction; import org.elasticsearch.xpack.core.security.action.role.DeleteRoleAction; @@ -287,6 +288,7 @@ public List> getClientActions() { InvalidateTokenAction.INSTANCE, GetCertificateInfoAction.INSTANCE, RefreshTokenAction.INSTANCE, + CreateApiKeyAction.INSTANCE, // upgrade IndexUpgradeInfoAction.INSTANCE, IndexUpgradeAction.INSTANCE, diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java index 997f04e33bd77..119e18b1b27bd 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java @@ -90,10 +90,14 @@ private XPackSettings() { public static final Setting RESERVED_REALM_ENABLED_SETTING = Setting.boolSetting("xpack.security.authc.reserved_realm.enabled", true, Setting.Property.NodeScope); - /** Setting for enabling or disabling the token service. Defaults to true */ + /** Setting for enabling or disabling the token service. Defaults to the value of https being enabled */ public static final Setting TOKEN_SERVICE_ENABLED_SETTING = Setting.boolSetting("xpack.security.authc.token.enabled", XPackSettings.HTTP_SSL_ENABLED::getRaw, Setting.Property.NodeScope); + /** Setting for enabling or disabling the api key service. Defaults to the value of https being enabled */ + public static final Setting API_KEY_SERVICE_ENABLED_SETTING = Setting.boolSetting("xpack.security.authc.api_key.enabled", + XPackSettings.HTTP_SSL_ENABLED::getRaw, Setting.Property.NodeScope); + /** Setting for enabling or disabling FIPS mode. Defaults to false */ public static final Setting FIPS_MODE_ENABLED = Setting.boolSetting("xpack.security.fips_mode.enabled", false, Property.NodeScope); @@ -182,6 +186,7 @@ public static List> getAllSettings() { settings.add(HTTP_SSL_ENABLED); settings.add(RESERVED_REALM_ENABLED_SETTING); settings.add(TOKEN_SERVICE_ENABLED_SETTING); + settings.add(API_KEY_SERVICE_ENABLED_SETTING); settings.add(SQL_ENABLED); settings.add(USER_SETTING); settings.add(ROLLUP_ENABLED); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyAction.java new file mode 100644 index 0000000000000..5d211ea70b522 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyAction.java @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.Action; +import org.elasticsearch.common.io.stream.Writeable; + +/** + * Action for the creation of an API key + */ +public final class CreateApiKeyAction extends Action { + + public static final String NAME = "cluster:admin/xpack/security/api_key/create"; + public static final CreateApiKeyAction INSTANCE = new CreateApiKeyAction(); + + private CreateApiKeyAction() { + super(NAME); + } + + @Override + public CreateApiKeyResponse newResponse() { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public Writeable.Reader getResponseReader() { + return CreateApiKeyResponse::new; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java new file mode 100644 index 0000000000000..d8d5a3e1fc69b --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +/** + * Request class used for the creation of an API key. The request requires a name to be provided + * and optionally an expiration time and permission limitation can be provided. + */ +public final class CreateApiKeyRequest extends ActionRequest { + + private String name; + private TimeValue expiration; + private List roleDescriptors = Collections.emptyList(); + private WriteRequest.RefreshPolicy refreshPolicy = WriteRequest.RefreshPolicy.WAIT_UNTIL; + + public CreateApiKeyRequest() {} + + public CreateApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.name = in.readString(); + this.expiration = in.readOptionalTimeValue(); + this.roleDescriptors = Collections.unmodifiableList(in.readList(RoleDescriptor::new)); + this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); + } + + 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 TimeValue getExpiration() { + return expiration; + } + + public void setExpiration(TimeValue expiration) { + this.expiration = expiration; + } + + public List getRoleDescriptors() { + return roleDescriptors; + } + + public void setRoleDescriptors(List roleDescriptors) { + this.roleDescriptors = Collections.unmodifiableList(Objects.requireNonNull(roleDescriptors, "role descriptors may not be null")); + } + + public WriteRequest.RefreshPolicy getRefreshPolicy() { + return refreshPolicy; + } + + public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { + this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(name)) { + validationException = addValidationError("name is required", validationException); + } else { + if (name.length() > 256) { + validationException = addValidationError("name may not be more than 256 characters long", validationException); + } + if (name.equals(name.trim()) == false) { + validationException = addValidationError("name may not begin or end with whitespace", validationException); + } + if (name.startsWith("_")) { + validationException = addValidationError("name may not begin with an underscore", validationException); + } + } + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(name); + out.writeOptionalTimeValue(expiration); + out.writeList(roleDescriptors); + refreshPolicy.writeTo(out); + } + + @Override + public void readFrom(StreamInput in) { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java new file mode 100644 index 0000000000000..5423b02003592 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequestBuilder; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.client.ElasticsearchClient; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.util.List; + +/** + * Request builder for populating a {@link CreateApiKeyRequest} + */ +public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder { + + public CreateApiKeyRequestBuilder(ElasticsearchClient client) { + super(client, CreateApiKeyAction.INSTANCE, new CreateApiKeyRequest()); + } + + public CreateApiKeyRequestBuilder setName(String name) { + request.setName(name); + return this; + } + + public CreateApiKeyRequestBuilder setExpiration(TimeValue expiration) { + request.setExpiration(expiration); + return this; + } + + public CreateApiKeyRequestBuilder setRoleDescriptors(List roleDescriptors) { + request.setRoleDescriptors(roleDescriptors); + return this; + } + + public CreateApiKeyRequestBuilder setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { + request.setRefreshPolicy(refreshPolicy); + return this; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java new file mode 100644 index 0000000000000..eb327b459f055 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.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; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.settings.SecureString; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; + +/** + * Response for the successful creation of an api key + */ +public final class CreateApiKeyResponse extends ActionResponse { + + private final String name; + private final String id; + private final SecureString key; + private final Instant expiration; + + public CreateApiKeyResponse(String name, String id, SecureString key, Instant expiration) { + this.name = name; + this.id = id; + this.key = key; + this.expiration = expiration; + } + + public CreateApiKeyResponse(StreamInput in) throws IOException { + super(in); + this.name = in.readString(); + this.id = in.readString(); + byte[] bytes = null; + try { + bytes = in.readByteArray(); + this.key = new SecureString(CharArrays.utf8BytesToChars(bytes)); + } finally { + if (bytes != null) { + Arrays.fill(bytes, (byte) 0); + } + } + this.expiration = in.readOptionalInstant(); + } + + public String getName() { + return name; + } + + public String getId() { + return id; + } + + public SecureString getKey() { + return key; + } + + @Nullable + public Instant getExpiration() { + return expiration; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(name); + out.writeString(id); + byte[] bytes = null; + try { + bytes = CharArrays.toUtf8Bytes(key.getChars()); + out.writeByteArray(bytes); + } finally { + if (bytes != null) { + Arrays.fill(bytes, (byte) 0); + } + } + out.writeOptionalInstant(expiration); + } + + @Override + public void readFrom(StreamInput in) { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/GetRolesResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/GetRolesResponse.java index 93c9d6bca9b64..27079eebcc36b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/GetRolesResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/GetRolesResponse.java @@ -37,7 +37,7 @@ public void readFrom(StreamInput in) throws IOException { int size = in.readVInt(); roles = new RoleDescriptor[size]; for (int i = 0; i < size; i++) { - roles[i] = RoleDescriptor.readFrom(in); + roles[i] = new RoleDescriptor(in); } } @@ -46,7 +46,7 @@ public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); out.writeVInt(roles.length); for (RoleDescriptor role : roles) { - RoleDescriptor.writeTo(role, out); + role.writeTo(out); } } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java index 9efbf985b832e..80c029aedb475 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java @@ -43,7 +43,7 @@ * A holder for a Role that contains user-readable information about the Role * without containing the actual Role object. */ -public class RoleDescriptor implements ToXContentObject { +public class RoleDescriptor implements ToXContentObject, Writeable { public static final String ROLE_TYPE = "role"; @@ -110,6 +110,27 @@ public RoleDescriptor(String name, Collections.singletonMap("enabled", true); } + public RoleDescriptor(StreamInput in) throws IOException { + this.name = in.readString(); + this.clusterPrivileges = in.readStringArray(); + int size = in.readVInt(); + this.indicesPrivileges = new IndicesPrivileges[size]; + for (int i = 0; i < size; i++) { + indicesPrivileges[i] = new IndicesPrivileges(in); + } + this.runAs = in.readStringArray(); + this.metadata = in.readMap(); + this.transientMetadata = in.readMap(); + + if (in.getVersion().onOrAfter(Version.V_6_4_0)) { + this.applicationPrivileges = in.readArray(ApplicationResourcePrivileges::new, ApplicationResourcePrivileges[]::new); + this.conditionalClusterPrivileges = ConditionalClusterPrivileges.readArray(in); + } else { + this.applicationPrivileges = ApplicationResourcePrivileges.NONE; + this.conditionalClusterPrivileges = ConditionalClusterPrivileges.EMPTY_ARRAY; + } + } + public String getName() { return this.name; } @@ -232,46 +253,20 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params, boolea return builder.endObject(); } - public static RoleDescriptor readFrom(StreamInput in) throws IOException { - String name = in.readString(); - String[] clusterPrivileges = in.readStringArray(); - int size = in.readVInt(); - IndicesPrivileges[] indicesPrivileges = new IndicesPrivileges[size]; - for (int i = 0; i < size; i++) { - indicesPrivileges[i] = new IndicesPrivileges(in); - } - String[] runAs = in.readStringArray(); - Map metadata = in.readMap(); - - final Map transientMetadata = in.readMap(); - - final ApplicationResourcePrivileges[] applicationPrivileges; - final ConditionalClusterPrivilege[] conditionalClusterPrivileges; - if (in.getVersion().onOrAfter(Version.V_6_4_0)) { - applicationPrivileges = in.readArray(ApplicationResourcePrivileges::new, ApplicationResourcePrivileges[]::new); - conditionalClusterPrivileges = ConditionalClusterPrivileges.readArray(in); - } else { - applicationPrivileges = ApplicationResourcePrivileges.NONE; - conditionalClusterPrivileges = ConditionalClusterPrivileges.EMPTY_ARRAY; - } - - return new RoleDescriptor(name, clusterPrivileges, indicesPrivileges, applicationPrivileges, conditionalClusterPrivileges, - runAs, metadata, transientMetadata); - } - - public static void writeTo(RoleDescriptor descriptor, StreamOutput out) throws IOException { - out.writeString(descriptor.name); - out.writeStringArray(descriptor.clusterPrivileges); - out.writeVInt(descriptor.indicesPrivileges.length); - for (IndicesPrivileges group : descriptor.indicesPrivileges) { + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeStringArray(clusterPrivileges); + out.writeVInt(indicesPrivileges.length); + for (IndicesPrivileges group : indicesPrivileges) { group.writeTo(out); } - out.writeStringArray(descriptor.runAs); - out.writeMap(descriptor.metadata); - out.writeMap(descriptor.transientMetadata); + out.writeStringArray(runAs); + out.writeMap(metadata); + out.writeMap(transientMetadata); if (out.getVersion().onOrAfter(Version.V_6_4_0)) { - out.writeArray(ApplicationResourcePrivileges::write, descriptor.applicationPrivileges); - ConditionalClusterPrivileges.writeArray(out, descriptor.getConditionalClusterPrivileges()); + out.writeArray(ApplicationResourcePrivileges::write, applicationPrivileges); + ConditionalClusterPrivileges.writeArray(out, getConditionalClusterPrivileges()); } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java index ef59f870c6854..bf18c79d55a80 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java @@ -10,6 +10,10 @@ import org.elasticsearch.client.ElasticsearchClient; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesRequestBuilder; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; @@ -330,6 +334,14 @@ public void invalidateToken(InvalidateTokenRequest request, ActionListener listener) { + client.execute(CreateApiKeyAction.INSTANCE, request, listener); + } + public SamlAuthenticateRequestBuilder prepareSamlAuthenticate(byte[] xmlContent, List validIds) { final SamlAuthenticateRequestBuilder builder = new SamlAuthenticateRequestBuilder(client); builder.saml(xmlContent); diff --git a/x-pack/plugin/core/src/main/resources/security-index-template.json b/x-pack/plugin/core/src/main/resources/security-index-template.json index bac5930c0d5c9..073db1830598f 100644 --- a/x-pack/plugin/core/src/main/resources/security-index-template.json +++ b/x-pack/plugin/core/src/main/resources/security-index-template.json @@ -149,6 +149,33 @@ "type" : "date", "format" : "epoch_millis" }, + "api_key_hash" : { + "type" : "keyword", + "index": false, + "doc_values": false + }, + "role_descriptors" : { + "type" : "object", + "dynamic" : true + }, + "version" : { + "type" : "integer" + }, + "creator" : { + "type" : "object", + "properties" : { + "principal" : { + "type": "keyword" + }, + "metadata" : { + "type" : "object", + "dynamic" : true + }, + "realm" : { + "type" : "keyword" + } + } + }, "rules" : { "type" : "object", "dynamic" : true diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java new file mode 100644 index 0000000000000..654d56b42130e --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +public class CreateApiKeyRequestTests extends ESTestCase { + + public void testNameValidation() { + final String name = randomAlphaOfLengthBetween(1, 256); + 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")); + + 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); + assertThat(ve.validationErrors().size(), is(1)); + assertThat(ve.validationErrors().get(0), containsString("name may not be more than 256 characters long")); + + request.setName(" leading space"); + ve = request.validate(); + assertNotNull(ve); + assertThat(ve.validationErrors().size(), is(1)); + assertThat(ve.validationErrors().get(0), containsString("name may not begin or end with whitespace")); + + request.setName("trailing space "); + ve = request.validate(); + assertNotNull(ve); + assertThat(ve.validationErrors().size(), is(1)); + assertThat(ve.validationErrors().get(0), containsString("name may not begin or end with whitespace")); + + request.setName(" leading and trailing space "); + ve = request.validate(); + assertNotNull(ve); + assertThat(ve.validationErrors().size(), is(1)); + assertThat(ve.validationErrors().get(0), containsString("name may not begin or end with whitespace")); + + request.setName("inner space"); + ve = request.validate(); + assertNull(ve); + + request.setName("_foo"); + ve = request.validate(); + assertNotNull(ve); + assertThat(ve.validationErrors().size(), is(1)); + assertThat(ve.validationErrors().get(0), containsString("name may not begin with an underscore")); + } + + public void testSerialization() throws IOException { + final String name = randomAlphaOfLengthBetween(1, 256); + final TimeValue expiration = randomBoolean() ? null : + TimeValue.parseTimeValue(randomTimeValue(), "test serialization of create api key"); + final WriteRequest.RefreshPolicy refreshPolicy = randomFrom(WriteRequest.RefreshPolicy.values()); + final int numDescriptors = randomIntBetween(0, 4); + final List descriptorList = new ArrayList<>(); + for (int i = 0; i < numDescriptors; i++) { + descriptorList.add(new RoleDescriptor("role_" + i, new String[] { "all" }, null, null)); + } + + final CreateApiKeyRequest request = new CreateApiKeyRequest(); + request.setName(name); + request.setExpiration(expiration); + + if (refreshPolicy != request.getRefreshPolicy() || randomBoolean()) { + request.setRefreshPolicy(refreshPolicy); + } + if (descriptorList.isEmpty() == false || randomBoolean()) { + request.setRoleDescriptors(descriptorList); + } + + try (BytesStreamOutput out = new BytesStreamOutput()) { + request.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + final CreateApiKeyRequest serialized = new CreateApiKeyRequest(in); + assertEquals(name, serialized.getName()); + assertEquals(expiration, serialized.getExpiration()); + assertEquals(refreshPolicy, serialized.getRefreshPolicy()); + assertEquals(descriptorList, serialized.getRoleDescriptors()); + } + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java new file mode 100644 index 0000000000000..2fe493823f69f --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public class CreateApiKeyResponseTests extends ESTestCase { + + public void testSerialization() throws IOException { + final String name = randomAlphaOfLengthBetween(1, 256); + final SecureString key = new SecureString(UUIDs.randomBase64UUID().toCharArray()); + final Instant expiration = randomBoolean() ? Instant.now().plus(7L, ChronoUnit.DAYS) : null; + final String id = randomAlphaOfLength(100); + + final CreateApiKeyResponse response = new CreateApiKeyResponse(name, id, key, expiration); + try (BytesStreamOutput out = new BytesStreamOutput()) { + response.writeTo(out); + + try (StreamInput in = out.bytes().streamInput()) { + CreateApiKeyResponse serialized = new CreateApiKeyResponse(in); + assertEquals(name, serialized.getName()); + assertEquals(id, serialized.getId()); + assertEquals(key, serialized.getKey()); + assertEquals(expiration, serialized.getExpiration()); + } + } + } +} 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 b0c1acba7e215..285c806a0ec25 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 @@ -84,6 +84,7 @@ import org.elasticsearch.xpack.core.security.SecurityExtension; import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction; @@ -134,6 +135,7 @@ import org.elasticsearch.xpack.core.ssl.action.TransportGetCertificateInfoAction; import org.elasticsearch.xpack.core.ssl.rest.RestGetCertificateInfoAction; import org.elasticsearch.xpack.core.template.TemplateUtils; +import org.elasticsearch.xpack.security.action.TransportCreateApiKeyAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.interceptor.BulkShardRequestInterceptor; import org.elasticsearch.xpack.security.action.interceptor.IndicesAliasesRequestInterceptor; @@ -172,6 +174,7 @@ import org.elasticsearch.xpack.security.audit.index.IndexAuditTrail; import org.elasticsearch.xpack.security.audit.index.IndexNameResolver; import org.elasticsearch.xpack.security.audit.logfile.LoggingAuditTrail; +import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.authc.AuthenticationService; import org.elasticsearch.xpack.security.authc.InternalRealms; import org.elasticsearch.xpack.security.authc.Realms; @@ -425,6 +428,9 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste this.tokenService.set(tokenService); components.add(tokenService); + final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex.get(), clusterService); + components.add(apiKeyService); + // realms construction final NativeUsersStore nativeUsersStore = new NativeUsersStore(settings, client, securityIndex.get()); final NativeRoleMappingStore nativeRoleMappingStore = new NativeRoleMappingStore(settings, client, securityIndex.get()); @@ -620,6 +626,7 @@ public static List> getSettings(boolean transportClientMode, List(SamlInvalidateSessionAction.INSTANCE, TransportSamlInvalidateSessionAction.class), new ActionHandler<>(GetPrivilegesAction.INSTANCE, TransportGetPrivilegesAction.class), new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), - new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class) + new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), + new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java new file mode 100644 index 0000000000000..dc00730906465 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; + +import java.util.ArrayList; + +/** + * Implementation of the action needed to create an API key + */ +public final class TransportCreateApiKeyAction extends HandledTransportAction { + + private final ApiKeyService apiKeyService; + private final CompositeRolesStore compositeRolesStore; + private final SecurityContext securityContext; + + @Inject + public TransportCreateApiKeyAction(Settings settings, TransportService transportService, ActionFilters actionFilters, + ApiKeyService apiKeyService, CompositeRolesStore compositeRolesStore, SecurityContext context) { + super(settings, CreateApiKeyAction.NAME, transportService, actionFilters, + (Writeable.Reader) CreateApiKeyRequest::new); + this.apiKeyService = apiKeyService; + this.compositeRolesStore = compositeRolesStore; + this.securityContext = context; + } + + @Override + protected void doExecute(Task task, CreateApiKeyRequest request, ActionListener listener) { + final Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + listener.onFailure(new IllegalStateException("authentication is required")); + } else if (request.getRoleDescriptors() == null || request.getRoleDescriptors().isEmpty()) { + compositeRolesStore.getRoleDescriptors(Sets.newHashSet(authentication.getUser().roles()), + ActionListener.wrap(rdSet -> { + request.setRoleDescriptors(new ArrayList<>(rdSet)); + apiKeyService.createApiKey(authentication, request, listener); + }, listener::onFailure)); + } else { + apiKeyService.createApiKey(authentication, request, listener); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java new file mode 100644 index 0000000000000..ba9086870addb --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.index.IndexAction; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; + +import javax.crypto.SecretKeyFactory; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.Arrays; +import java.util.Locale; +import java.util.function.Function; + +import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; + +public class ApiKeyService { + + private static final Logger logger = LogManager.getLogger(ApiKeyService.class); + private static final String TYPE = "doc"; + public static final Setting PASSWORD_HASHING_ALGORITHM = new Setting<>( + "xpack.security.authc.api_key_hashing.algorithm", "pbkdf2", Function.identity(), (v, s) -> { + if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) { + throw new IllegalArgumentException("Invalid algorithm: " + v + ". Valid values for password hashing are " + + Hasher.getAvailableAlgoStoredHash().toString()); + } else if (v.regionMatches(true, 0, "pbkdf2", 0, "pbkdf2".length())) { + try { + SecretKeyFactory.getInstance("PBKDF2withHMACSHA512"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException( + "Support for PBKDF2WithHMACSHA512 must be available in order to use any of the " + + "PBKDF2 algorithms for the [xpack.security.authc.api_key.hashing.algorithm] setting.", e); + } + } + }, Setting.Property.NodeScope); + + private final Clock clock; + private final Client client; + private final SecurityIndexManager securityIndex; + private final ClusterService clusterService; + private final Hasher hasher; + private final boolean enabled; + + public ApiKeyService(Settings settings, Clock clock, Client client, + SecurityIndexManager securityIndex, ClusterService clusterService) { + this.clock = clock; + this.client = client; + this.securityIndex = securityIndex; + this.clusterService = clusterService; + this.enabled = XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.get(settings); + this.hasher = Hasher.resolve(PASSWORD_HASHING_ALGORITHM.get(settings)); + } + + /** + * Asynchronously creates a new API key based off of the request and authentication + * @param authentication the authentication that this api key should be based off of + * @param request the request to create the api key included any permission restrictions + * @param listener the listener that will be used to notify of completion + */ + public void createApiKey(Authentication authentication, CreateApiKeyRequest request, ActionListener listener) { + ensureEnabled(); + if (authentication == null) { + listener.onFailure(new IllegalArgumentException("authentication must be provided")); + } else { + final Instant created = clock.instant(); + final Instant expiration = getApiKeyExpiration(created, request); + final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); + final Version version = clusterService.state().nodes().getMinNodeVersion(); + if (version.before(Version.V_7_0_0_alpha1)) { // TODO(jaymode) change to V6_6_0 on backport! + logger.warn("nodes prior to the minimum supported version for api keys {} exist in the cluster; these nodes will not be " + + "able to use api keys", Version.V_7_0_0_alpha1); + } + + final char[] keyHash = hasher.hash(apiKey); + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject() + .field("doc_type", "api_key") + .field("creation_time", created.toEpochMilli()) + .field("expiration_time", expiration == null ? null : expiration.toEpochMilli()); + + byte[] utf8Bytes = null; + try { + utf8Bytes = CharArrays.toUtf8Bytes(keyHash); + builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length); + } finally { + if (utf8Bytes != null) { + Arrays.fill(utf8Bytes, (byte) 0); + } + } + + builder.array("role_descriptors", request.getRoleDescriptors()) + .field("name", request.getName()) + .field("version", version.id) + .startObject("creator") + .field("principal", authentication.getUser().principal()) + .field("metadata", authentication.getUser().metadata()) + .field("realm", authentication.getLookedUpBy() == null ? + authentication.getAuthenticatedBy().getName() : authentication.getLookedUpBy().getName()) + .endObject() + .endObject(); + final IndexRequest indexRequest = + client.prepareIndex(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE) + .setSource(builder) + .setRefreshPolicy(request.getRefreshPolicy()) + .request(); + securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () -> + executeAsyncWithOrigin(client, SECURITY_ORIGIN, IndexAction.INSTANCE, indexRequest, + ActionListener.wrap(indexResponse -> + listener.onResponse(new CreateApiKeyResponse(request.getName(), indexResponse.getId(), apiKey, expiration)), + listener::onFailure))); + } catch (IOException e) { + listener.onFailure(e); + } finally { + Arrays.fill(keyHash, (char) 0); + } + } + } + + private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { + if (request.getExpiration() != null) { + return now.plusSeconds(request.getExpiration().getSeconds()); + } else { + return null; + } + } + + private void ensureEnabled() { + if (enabled == false) { + throw new IllegalStateException("tokens are not enabled"); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index 3ce0906a4e2a3..b9ba941917036 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + package org.elasticsearch.xpack.security.authc; import org.apache.logging.log4j.message.ParameterizedMessage; @@ -337,7 +338,7 @@ private void decodeAndValidateToken(String token, ActionListener list }, listener::onFailure)); } - /* + /** * Asynchronously decodes the string representation of a {@link UserToken}. The process for * this is asynchronous as we may need to compute a key, which can be computationally expensive * so this should not block the current thread, which is typically a network thread. A second diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index 139393b522e1c..bc4e819a00352 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -6,6 +6,7 @@ package org.elasticsearch.xpack.security.authz.store; import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.ElasticsearchException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.common.Nullable; @@ -177,6 +178,16 @@ public void roles(Set roleNames, FieldPermissionsCache fieldPermissionsC } } + public void getRoleDescriptors(Set roleNames, ActionListener> listener) { + roleDescriptors(roleNames, ActionListener.wrap(rolesRetrievalResult -> { + if (rolesRetrievalResult.isSuccess()) { + listener.onResponse(rolesRetrievalResult.getRoleDescriptors()); + } else { + listener.onFailure(new ElasticsearchException("role retrieval had one or more failures")); + } + }, listener::onFailure)); + } + private void roleDescriptors(Set roleNames, ActionListener rolesResultListener) { final Set filteredRoleNames = roleNames.stream().filter((s) -> { if (negativeLookupCache.get(s) != null) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java new file mode 100644 index 0000000000000..8e578a0f1e2cc --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.elasticsearch.client.Client; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.test.SecurityIntegTestCase; +import org.elasticsearch.test.SecuritySettingsSource; +import org.elasticsearch.test.SecuritySettingsSourceField; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.client.SecurityClient; +import org.junit.After; +import org.junit.Before; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +public class ApiKeyIntegTests extends SecurityIntegTestCase { + + @Override + public Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) + .build(); + } + + @Before + public void waitForSecurityIndexWritable() throws Exception { + assertSecurityIndexActive(); + } + + @After + public void wipeSecurityIndex() { + deleteSecurityIndex(); + } + + public void testCreateApiKey() { + final Instant start = Instant.now(); + 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))); + SecurityClient securityClient = new SecurityClient(client); + CreateApiKeyResponse response = securityClient.prepareCreateApiKey() + .setName("test key") + .setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L))) + .setRoleDescriptors(Collections.singletonList(descriptor)) + .get(); + + assertEquals("test key", response.getName()); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + Instant expiration = response.getExpiration(); + final long daysBetween = ChronoUnit.DAYS.between(start, expiration); + assertThat(daysBetween, is(7L)); + + // simple one + response = securityClient.prepareCreateApiKey().setName("simple").get(); + assertEquals("simple", response.getName()); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + assertThat(response.getId(), not(containsString(new String(response.getKey().getChars())))); + assertNull(response.getExpiration()); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java index 08e4b1123c70a..9d994bd71f585 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java @@ -54,7 +54,7 @@ public void testIndexGroup() throws Exception { assertEquals("{\"names\":[\"idx\"],\"privileges\":[\"priv\"]}", Strings.toString(b)); } - public void testToString() throws Exception { + public void testToString() { RoleDescriptor.IndicesPrivileges[] groups = new RoleDescriptor.IndicesPrivileges[] { RoleDescriptor.IndicesPrivileges.builder() .indices("i1", "i2") @@ -236,12 +236,12 @@ public void testSerialization() throws Exception { Map metadata = randomBoolean() ? MetadataUtils.DEFAULT_RESERVED_METADATA : null; final RoleDescriptor descriptor = new RoleDescriptor("test", new String[]{"all", "none"}, groups, applicationPrivileges, conditionalClusterPrivileges, new String[] { "sudo" }, metadata, null); - RoleDescriptor.writeTo(descriptor, output); + descriptor.writeTo(output); final NamedWriteableRegistry registry = new NamedWriteableRegistry(new XPackClientPlugin(Settings.EMPTY).getNamedWriteables()); StreamInput streamInput = new NamedWriteableAwareStreamInput(ByteBufferStreamInput.wrap(BytesReference.toBytes(output.bytes())), registry); streamInput.setVersion(version); - final RoleDescriptor serialized = RoleDescriptor.readFrom(streamInput); + final RoleDescriptor serialized = new RoleDescriptor(streamInput); assertEquals(descriptor, serialized); } @@ -270,7 +270,7 @@ public void testParseEmptyQueryUsingDeprecatedIndicesField() throws Exception { } public void testParseIgnoresTransientMetadata() throws Exception { - final RoleDescriptor descriptor = new RoleDescriptor("test", new String[] { "all" }, null, null, + final RoleDescriptor descriptor = new RoleDescriptor("test", new String[] { "all" }, null, null, null, null, Collections.singletonMap("_unlicensed_feature", true), Collections.singletonMap("foo", "bar")); XContentBuilder b = jsonBuilder(); descriptor.toXContent(b, ToXContent.EMPTY_PARAMS); From c6dcda719f451faff0670a60a6d82bd8156516ee Mon Sep 17 00:00:00 2001 From: jaymode Date: Mon, 5 Nov 2018 12:01:14 -0700 Subject: [PATCH 02/26] fix compile after merging master --- .../security/action/TransportCreateApiKeyAction.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java index dc00730906465..eeb5bb673011e 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java @@ -11,7 +11,6 @@ import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.common.inject.Inject; import org.elasticsearch.common.io.stream.Writeable; -import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; @@ -35,10 +34,9 @@ public final class TransportCreateApiKeyAction extends HandledTransportAction) CreateApiKeyRequest::new); + public TransportCreateApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService, + CompositeRolesStore compositeRolesStore, SecurityContext context) { + super(CreateApiKeyAction.NAME, transportService, actionFilters, (Writeable.Reader) CreateApiKeyRequest::new); this.apiKeyService = apiKeyService; this.compositeRolesStore = compositeRolesStore; this.securityContext = context; From d1e838b76ceac51c57285dfeedbabfd9db546179 Mon Sep 17 00:00:00 2001 From: jaymode Date: Thu, 8 Nov 2018 13:32:05 -0700 Subject: [PATCH 03/26] fix compile after merge --- .../org/elasticsearch/xpack/security/authc/ApiKeyService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index ba9086870addb..ba1ea3ecb92b9 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -92,9 +92,9 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ final Instant expiration = getApiKeyExpiration(created, request); final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); final Version version = clusterService.state().nodes().getMinNodeVersion(); - if (version.before(Version.V_7_0_0_alpha1)) { // TODO(jaymode) change to V6_6_0 on backport! + if (version.before(Version.V_7_0_0)) { // TODO(jaymode) change to V6_6_0 on backport! logger.warn("nodes prior to the minimum supported version for api keys {} exist in the cluster; these nodes will not be " + - "able to use api keys", Version.V_7_0_0_alpha1); + "able to use api keys", Version.V_7_0_0); } final char[] keyHash = hasher.hash(apiKey); From 39477a243971d7ccdaed01ff17ca6a0055d2b5e0 Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Wed, 14 Nov 2018 07:53:52 -0700 Subject: [PATCH 04/26] Implement verification of API keys (#35318) This change implements the verification of api keys in the ApiKeyService. There is no integration into the AuthenticationService as part of this change; this will be done in a future change. Verification of an API key involves validating the provided key with the hash stored in the document and then ensuring that the token is not expired. A conscious decision has been made to always validate the hash and then check expiration. This is done to prevent leaking that a given key has expired. --- .../DefaultAuthenticationFailureHandler.java | 6 +- ...aultAuthenticationFailureHandlerTests.java | 5 +- .../xpack/security/Security.java | 8 + .../xpack/security/authc/ApiKeyService.java | 152 ++++++++++++++++++ .../security/authc/ApiKeyServiceTests.java | 113 +++++++++++++ 5 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java index 736b9378e3876..a7c51b206db9c 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java @@ -77,10 +77,12 @@ private static Integer authSchemePriority(final String headerValue) { return 0; } else if (headerValue.regionMatches(true, 0, "bearer", 0, "bearer".length())) { return 1; - } else if (headerValue.regionMatches(true, 0, "basic", 0, "basic".length())) { + } else if (headerValue.regionMatches(true, 0, "apikey", 0, "apikey".length())) { return 2; - } else { + } else if (headerValue.regionMatches(true, 0, "basic", 0, "basic".length())) { return 3; + } else { + return 4; } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java index 15593f0b82ea5..24f9d16324f2d 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java @@ -111,8 +111,9 @@ public void testSortsWWWAuthenticateHeaderValues() { final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""; final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\""; final String negotiateAuthScheme = randomFrom("Negotiate", "Negotiate Ijoijksdk"); + final String apiKeyAuthScheme = "ApiKey"; final Map> failureResponeHeaders = new HashMap<>(); - final List supportedSchemes = Arrays.asList(basicAuthScheme, bearerAuthScheme, negotiateAuthScheme); + final List supportedSchemes = Arrays.asList(basicAuthScheme, bearerAuthScheme, negotiateAuthScheme, apiKeyAuthScheme); Collections.shuffle(supportedSchemes, random()); failureResponeHeaders.put("WWW-Authenticate", supportedSchemes); final DefaultAuthenticationFailureHandler failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders); @@ -123,7 +124,7 @@ public void testSortsWWWAuthenticateHeaderValues() { assertThat(ese, is(notNullValue())); assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue())); assertThat(ese.getMessage(), equalTo("error attempting to authenticate request")); - assertWWWAuthenticateWithSchemes(ese, negotiateAuthScheme, bearerAuthScheme, basicAuthScheme); + assertWWWAuthenticateWithSchemes(ese, negotiateAuthScheme, bearerAuthScheme, apiKeyAuthScheme, basicAuthScheme); } private void assertWWWAuthenticateWithSchemes(final ElasticsearchSecurityException ese, final String... schemes) { 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 a09ad6dbbcb40..d1dbea8898546 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 @@ -251,6 +251,7 @@ import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.elasticsearch.cluster.metadata.IndexMetaData.INDEX_FORMAT_SETTING; +import static org.elasticsearch.xpack.core.XPackSettings.API_KEY_SERVICE_ENABLED_SETTING; import static org.elasticsearch.xpack.core.XPackSettings.HTTP_SSL_ENABLED; import static org.elasticsearch.xpack.security.support.SecurityIndexManager.INTERNAL_INDEX_FORMAT; import static org.elasticsearch.xpack.security.support.SecurityIndexManager.SECURITY_INDEX_NAME; @@ -540,6 +541,13 @@ private AuthenticationFailureHandler createAuthenticationFailureHandler(final Re defaultFailureResponseHeaders.get("WWW-Authenticate").add(bearerScheme); } } + if (API_KEY_SERVICE_ENABLED_SETTING.get(settings)) { + final String apiKeyScheme = "ApiKey"; + if (defaultFailureResponseHeaders.computeIfAbsent("WWW-Authenticate", x -> new ArrayList<>()) + .contains(apiKeyScheme) == false) { + defaultFailureResponseHeaders.get("WWW-Authenticate").add(apiKeyScheme); + } + } failureHandler = new DefaultAuthenticationFailureHandler(defaultFailureResponseHeaders); } else { logger.debug("Using authentication failure handler from extension [" + extensionName + "]"); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index ba1ea3ecb92b9..69b3121acb638 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -10,32 +10,44 @@ import org.apache.logging.log4j.Logger; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexAction; import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.client.Client; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import javax.crypto.SecretKeyFactory; +import java.io.Closeable; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; import java.util.Arrays; +import java.util.Base64; +import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.function.Function; +import java.util.stream.Collectors; import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; @@ -142,6 +154,122 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ } } + /** + * Checks for the presence of a {@code Authorization} header with a value that starts with + * {@code ApiKey }. If found this will attempt to authenticate the key. + */ + void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener listener) { + if (enabled) { + final ApiKeyCredentials credentials; + try { + credentials = getCredentialsFromHeader(ctx); + } catch (IllegalArgumentException iae) { + listener.onResponse(AuthenticationResult.unsuccessful(iae.getMessage(), iae)); + return; + } + + if (credentials != null) { + final GetRequest getRequest = client.prepareGet(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE, credentials.getId()) + .setFetchSource(true).request(); + executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.wrap(response -> { + if (response.isExists()) { + try (ApiKeyCredentials ignore = credentials) { + validateApiKeyCredentials(response.getSource(), credentials, clock, listener); + } + } else { + credentials.close(); + listener.onResponse(AuthenticationResult.unsuccessful("unable to authenticate", null)); + } + }, e -> { + credentials.close(); + listener.onResponse(AuthenticationResult.unsuccessful("apikey auth encountered a failure", e)); + }), client::get); + } else { + listener.onResponse(AuthenticationResult.notHandled()); + } + } else { + listener.onResponse(AuthenticationResult.notHandled()); + } + } + + /** + * Validates the ApiKey using the source map + * @param source the source map from a get of the ApiKey document + * @param credentials the credentials provided by the user + * @param listener the listener to notify after verification + */ + static void validateApiKeyCredentials(Map source, ApiKeyCredentials credentials, Clock clock, + ActionListener listener) { + final String apiKeyHash = (String) source.get("api_key_hash"); + if (apiKeyHash == null) { + throw new IllegalStateException("api key hash is missing"); + } + final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials); + + if (verified) { + final Long expirationEpochMilli = (Long) source.get("expiration_time"); + if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) { + final String principal = Objects.requireNonNull((String) source.get("principal")); + final Map metadata = (Map) source.get("metadata"); + final List> roleDescriptors = (List>) source.get("role_descriptors"); + final String[] roleNames = roleDescriptors.stream() + .map(rdSource -> (String) rdSource.get("name")) + .collect(Collectors.toList()) + .toArray(Strings.EMPTY_ARRAY); + final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); + listener.onResponse(AuthenticationResult.success(apiKeyUser)); + } else { + listener.onResponse(AuthenticationResult.unsuccessful("api key is expired", null)); + } + } else { + listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null)); + } + } + + /** + * Gets the API Key from the Authorization header if the header begins with + * ApiKey + */ + static ApiKeyCredentials getCredentialsFromHeader(ThreadContext threadContext) { + String header = threadContext.getHeader("Authorization"); + if (Strings.hasText(header) && header.regionMatches(true, 0, "ApiKey ", 0, "ApiKey ".length()) + && header.length() > "ApiKey ".length()) { + final byte[] decodedApiKeyCredBytes = Base64.getDecoder().decode(header.substring("ApiKey ".length())); + char[] apiKeyCredChars = null; + try { + apiKeyCredChars = CharArrays.utf8BytesToChars(decodedApiKeyCredBytes); + int colonIndex = -1; + for (int i = 0; i < apiKeyCredChars.length; i++) { + if (apiKeyCredChars[i] == ':') { + colonIndex = i; + break; + } + } + + if (colonIndex < 1) { + throw new IllegalArgumentException("invalid ApiKey value"); + } + return new ApiKeyCredentials(new String(Arrays.copyOfRange(apiKeyCredChars, 0, colonIndex)), + new SecureString(Arrays.copyOfRange(apiKeyCredChars, colonIndex + 1, apiKeyCredChars.length))); + } finally { + if (apiKeyCredChars != null) { + Arrays.fill(apiKeyCredChars, (char) 0); + } + } + } + return null; + } + + private static boolean verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials) { + final char[] apiKeyHashChars = apiKeyHash.toCharArray(); + try { + Hasher hasher = Hasher.resolveFromHash(apiKeyHash.toCharArray()); + return hasher.verify(credentials.getKey(), apiKeyHashChars); + } finally { + Arrays.fill(apiKeyHashChars, (char) 0); + } + } + private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { if (request.getExpiration() != null) { return now.plusSeconds(request.getExpiration().getSeconds()); @@ -155,4 +283,28 @@ private void ensureEnabled() { throw new IllegalStateException("tokens are not enabled"); } } + + // package private class for testing + static final class ApiKeyCredentials implements Closeable { + private final String id; + private final SecureString key; + + ApiKeyCredentials(String id, SecureString key) { + this.id = id; + this.key = key; + } + + String getId() { + return id; + } + + SecureString getKey() { + return key; + } + + @Override + public void close() { + key.close(); + } + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java new file mode 100644 index 0000000000000..b7814df7f7fde --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.is; + +public class ApiKeyServiceTests extends ESTestCase { + + public void testGetCredentialsFromThreadContext() { + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + assertNull(ApiKeyService.getCredentialsFromHeader(threadContext)); + + final String apiKeyAuthScheme = randomFrom("apikey", "apiKey", "ApiKey", "APikey", "APIKEY"); + final String id = randomAlphaOfLength(12); + final String key = randomAlphaOfLength(16); + String headerValue = apiKeyAuthScheme + " " + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); + + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + threadContext.putHeader("Authorization", headerValue); + ApiKeyService.ApiKeyCredentials creds = ApiKeyService.getCredentialsFromHeader(threadContext); + assertNotNull(creds); + assertEquals(id, creds.getId()); + assertEquals(key, creds.getKey().toString()); + } + + // missing space + headerValue = apiKeyAuthScheme + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + threadContext.putHeader("Authorization", headerValue); + ApiKeyService.ApiKeyCredentials creds = ApiKeyService.getCredentialsFromHeader(threadContext); + assertNull(creds); + } + + // missing colon + headerValue = apiKeyAuthScheme + " " + Base64.getEncoder().encodeToString((id + key).getBytes(StandardCharsets.UTF_8)); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + threadContext.putHeader("Authorization", headerValue); + IllegalArgumentException e = + expectThrows(IllegalArgumentException.class, () -> ApiKeyService.getCredentialsFromHeader(threadContext)); + assertEquals("invalid ApiKey value", e.getMessage()); + } + } + + public void testValidateApiKey() throws Exception { + final String apiKey = randomAlphaOfLength(16); + Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT); + final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); + + Map sourceMap = new HashMap<>(); + sourceMap.put("api_key_hash", new String(hash)); + sourceMap.put("principal", "test_user"); + sourceMap.put("metadata", Collections.emptyMap()); + sourceMap.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); + + + ApiKeyService.ApiKeyCredentials creds = + new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); + PlainActionFuture future = new PlainActionFuture<>(); + ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); + AuthenticationResult result = future.get(); + assertNotNull(result); + assertTrue(result.isAuthenticated()); + assertThat(result.getUser().principal(), is("test_user")); + assertThat(result.getUser().roles(), arrayContaining("a role")); + assertThat(result.getUser().metadata(), is(Collections.emptyMap())); + + sourceMap.put("expiration_time", Clock.systemUTC().instant().plus(1L, ChronoUnit.HOURS).toEpochMilli()); + future = new PlainActionFuture<>(); + ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); + result = future.get(); + assertNotNull(result); + assertTrue(result.isAuthenticated()); + assertThat(result.getUser().principal(), is("test_user")); + assertThat(result.getUser().roles(), arrayContaining("a role")); + assertThat(result.getUser().metadata(), is(Collections.emptyMap())); + + sourceMap.put("expiration_time", Clock.systemUTC().instant().minus(1L, ChronoUnit.HOURS).toEpochMilli()); + future = new PlainActionFuture<>(); + ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); + result = future.get(); + assertNotNull(result); + assertFalse(result.isAuthenticated()); + + sourceMap.remove("expiration_time"); + creds = new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(randomAlphaOfLength(15).toCharArray())); + future = new PlainActionFuture<>(); + ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); + result = future.get(); + assertNotNull(result); + assertFalse(result.isAuthenticated()); + } +} From 22624cf21abbb134b3dfe56a2277289f70237888 Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Tue, 27 Nov 2018 08:23:47 -0700 Subject: [PATCH 05/26] Integrate api keys with the authentication service (#35555) This commit integrates usage of the api key service into the authentication service for verification of api keys. A bug was fixed in the validation of api keys where the structure of the document was not being used properly. Additionally, unit tests have been added for authentication with api keys. --- .../xpack/security/Security.java | 2 +- .../xpack/security/authc/ApiKeyService.java | 13 +- .../security/authc/AuthenticationService.java | 31 ++++- .../security/authc/ApiKeyServiceTests.java | 7 +- .../authc/AuthenticationServiceTests.java | 124 ++++++++++++++++-- 5 files changed, 154 insertions(+), 23 deletions(-) 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 d1dbea8898546..045690264bb0a 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 @@ -459,7 +459,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms); authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool, - anonymousUser, tokenService)); + anonymousUser, tokenService, apiKeyService)); components.add(authcService.get()); final NativePrivilegeStore privilegeStore = new NativePrivilegeStore(settings, client, securityIndex.get()); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 69b3121acb638..3673bba69a2e0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -178,11 +178,13 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener { credentials.close(); - listener.onResponse(AuthenticationResult.unsuccessful("apikey auth encountered a failure", e)); + listener.onResponse(AuthenticationResult.unsuccessful("apikey authentication for id " + credentials.getId() + + " encountered a failure", e)); }), client::get); } else { listener.onResponse(AuthenticationResult.notHandled()); @@ -209,8 +211,9 @@ static void validateApiKeyCredentials(Map source, ApiKeyCredenti if (verified) { final Long expirationEpochMilli = (Long) source.get("expiration_time"); if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) { - final String principal = Objects.requireNonNull((String) source.get("principal")); - final Map metadata = (Map) source.get("metadata"); + final Map creator = Objects.requireNonNull((Map) source.get("creator")); + final String principal = Objects.requireNonNull((String) creator.get("principal")); + final Map metadata = (Map) creator.get("metadata"); final List> roleDescriptors = (List>) source.get("role_descriptors"); final String[] roleNames = roleDescriptors.stream() .map(rdSource -> (String) rdSource.get("name")) @@ -219,7 +222,7 @@ static void validateApiKeyCredentials(Map source, ApiKeyCredenti final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); listener.onResponse(AuthenticationResult.success(apiKeyUser)); } else { - listener.onResponse(AuthenticationResult.unsuccessful("api key is expired", null)); + listener.onResponse(AuthenticationResult.terminate("api key is expired", null)); } } else { listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null)); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 0c3706d6f4ced..d304f5a997f77 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -61,12 +61,13 @@ public class AuthenticationService { private final String nodeName; private final AnonymousUser anonymousUser; private final TokenService tokenService; + private final ApiKeyService apiKeyService; private final boolean runAsEnabled; private final boolean isAnonymousUserEnabled; public AuthenticationService(Settings settings, Realms realms, AuditTrailService auditTrail, AuthenticationFailureHandler failureHandler, ThreadPool threadPool, - AnonymousUser anonymousUser, TokenService tokenService) { + AnonymousUser anonymousUser, TokenService tokenService, ApiKeyService apiKeyService) { this.nodeName = Node.NODE_NAME_SETTING.get(settings); this.realms = realms; this.auditTrail = auditTrail; @@ -76,6 +77,7 @@ public AuthenticationService(Settings settings, Realms realms, AuditTrailService this.runAsEnabled = AuthenticationServiceField.RUN_AS_ENABLED.get(settings); this.isAnonymousUserEnabled = AnonymousUser.isAnonymousEnabled(settings); this.tokenService = tokenService; + this.apiKeyService = apiKeyService; } /** @@ -181,7 +183,7 @@ private void authenticateAsync() { if (userToken != null) { writeAuthToContext(userToken.getAuthentication()); } else { - extractToken(this::consumeToken); + checkForApiKey(); } }, e -> { if (e instanceof ElasticsearchSecurityException && @@ -196,6 +198,31 @@ private void authenticateAsync() { }); } + private void checkForApiKey() { + apiKeyService.authenticateWithApiKeyIfPresent(threadContext, ActionListener.wrap(authResult -> { + if (authResult.isAuthenticated()) { + final User user = authResult.getUser(); + authenticatedBy = new RealmRef("_es_api_key", "_es_api_key", nodeName); + writeAuthToContext(new Authentication(user, authenticatedBy, null)); + } else if (authResult.getStatus() == AuthenticationResult.Status.TERMINATE) { + Exception e = (authResult.getException() != null) ? authResult.getException() + : Exceptions.authenticationError(authResult.getMessage()); + listener.onFailure(e); + } else { + if (authResult.getMessage() != null) { + if (authResult.getException() != null) { + logger.warn(new ParameterizedMessage("Authentication using apikey failed - {}", authResult.getMessage()), + authResult.getException()); + } else { + logger.warn("Authentication using apikey failed - {}", authResult.getMessage()); + } + } + extractToken(this::consumeToken); + } + }, + e -> listener.onFailure(request.exceptionProcessingRequest(e, null)))); + } + /** * Looks to see if the request contains an existing {@link Authentication} and if so, that authentication will be used. The * consumer is called if no exception was thrown while trying to read the authentication and may be called with a {@code null} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index b7814df7f7fde..b41c9dd9dfd98 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -69,10 +69,11 @@ public void testValidateApiKey() throws Exception { Map sourceMap = new HashMap<>(); sourceMap.put("api_key_hash", new String(hash)); - sourceMap.put("principal", "test_user"); - sourceMap.put("metadata", Collections.emptyMap()); sourceMap.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); - + Map creatorMap = new HashMap<>(); + creatorMap.put("principal", "test_user"); + creatorMap.put("metadata", Collections.emptyMap()); + sourceMap.put("creator", creatorMap); ApiKeyService.ApiKeyCredentials creds = new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index a43ad8c031b59..0e22aab8642cf 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -11,6 +11,7 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.get.GetAction; +import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetRequestBuilder; import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.get.MultiGetAction; @@ -28,6 +29,8 @@ import org.elasticsearch.client.Client; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.collect.MapBuilder; import org.elasticsearch.common.collect.Tuple; import org.elasticsearch.common.io.stream.BytesStreamOutput; @@ -37,8 +40,10 @@ import org.elasticsearch.common.transport.TransportAddress; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.json.JsonXContent; import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.index.get.GetResult; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestStatus; @@ -60,6 +65,7 @@ import org.elasticsearch.xpack.core.security.authc.DefaultAuthenticationFailureHandler; import org.elasticsearch.xpack.core.security.authc.Realm; import org.elasticsearch.xpack.core.security.authc.Realm.Factory; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.permission.Role; import org.elasticsearch.xpack.core.security.user.AnonymousUser; @@ -75,10 +81,14 @@ import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -127,6 +137,7 @@ public class AuthenticationServiceTests extends ESTestCase { private ThreadPool threadPool; private ThreadContext threadContext; private TokenService tokenService; + private ApiKeyService apiKeyService; private SecurityIndexManager securityIndex; private Client client; private InetSocketAddress remoteAddress; @@ -148,10 +159,11 @@ public void init() throws Exception { when(secondRealm.type()).thenReturn("second"); when(secondRealm.name()).thenReturn("second_realm"); Settings settings = Settings.builder() - .put("path.home", createTempDir()) - .put("node.name", "authc_test") - .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true) - .build(); + .put("path.home", createTempDir()) + .put("node.name", "authc_test") + .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true) + .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) + .build(); XPackLicenseState licenseState = mock(XPackLicenseState.class); when(licenseState.allowedRealmType()).thenReturn(XPackLicenseState.AllowedRealmType.ALL); when(licenseState.isAuthAllowed()).thenReturn(true); @@ -194,9 +206,10 @@ licenseState, threadContext, mock(ReservedRealm.class), Arrays.asList(firstRealm return null; }).when(securityIndex).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); ClusterService clusterService = ClusterServiceUtils.createClusterService(threadPool); + apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex, clusterService); tokenService = new TokenService(settings, Clock.systemUTC(), client, securityIndex, clusterService); - service = new AuthenticationService(settings, realms, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), tokenService); + service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), + threadPool, new AnonymousUser(settings), tokenService, apiKeyService); } @After @@ -462,7 +475,7 @@ public void testAutheticateTransportContextAndHeader() throws Exception { ThreadContext threadContext1 = threadPool1.getThreadContext(); service = new AuthenticationService(Settings.EMPTY, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool1, new AnonymousUser(Settings.EMPTY), - tokenService); + tokenService, apiKeyService); threadContext1.putTransient(AuthenticationField.AUTHENTICATION_KEY, authRef.get()); threadContext1.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); @@ -486,7 +499,7 @@ public void testAutheticateTransportContextAndHeader() throws Exception { try (ThreadContext.StoredContext ignore = threadContext2.stashContext()) { service = new AuthenticationService(Settings.EMPTY, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService); + tokenService, apiKeyService); threadContext2.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); BytesStreamOutput output = new BytesStreamOutput(); @@ -500,7 +513,7 @@ public void testAutheticateTransportContextAndHeader() throws Exception { threadPool2.getThreadContext().putHeader(AuthenticationField.AUTHENTICATION_KEY, header); service = new AuthenticationService(Settings.EMPTY, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService); + tokenService, apiKeyService); service.authenticate("_action", new InternalMessage(), SystemUser.INSTANCE, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), equalTo(user1)); @@ -536,7 +549,7 @@ public void testAnonymousUserRest() throws Exception { Settings settings = builder.build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService); + threadPool, anonymousUser, tokenService, apiKeyService); RestRequest request = new FakeRestRequest(); Authentication result = authenticateBlocking(request); @@ -554,7 +567,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { .build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService); + threadPool, anonymousUser, tokenService, apiKeyService); InternalMessage message = new InternalMessage(); Authentication result = authenticateBlocking("_action", message, null); @@ -569,7 +582,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { .build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService); + threadPool, anonymousUser, tokenService, apiKeyService); InternalMessage message = new InternalMessage(); @@ -1029,6 +1042,93 @@ public void testExpiredToken() throws Exception { } } + public void testApiKeyAuthInvalidHeader() { + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + final String invalidHeader = randomFrom("apikey", "apikey ", "apikey foo"); + threadContext.putHeader("Authorization", invalidHeader); + ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> authenticateBlocking("_action", message, null)); + assertThat(e.getMessage(), containsString("missing authentication token")); + assertEquals(RestStatus.UNAUTHORIZED, e.status()); + } + } + + @SuppressWarnings("unchecked") + public void testApiKeyAuth() { + final String id = randomAlphaOfLength(12); + final String key = UUIDs.randomBase64UUID(random()); + final String headerValue = "ApiKey " + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); + doAnswer(invocationOnMock -> { + final GetRequest request = (GetRequest) invocationOnMock.getArguments()[0]; + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + if (request.id().equals(id)) { + final Map source = new HashMap<>(); + source.put("doc_type", "api_key"); + source.put("creation_time", Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); + source.put("api_key_hash", new String(Hasher.BCRYPT4.hash(new SecureString(key.toCharArray())))); + source.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); + source.put("name", "my api key for testApiKeyAuth"); + Map creatorMap = new HashMap<>(); + creatorMap.put("principal", "johndoe"); + creatorMap.put("metadata", Collections.emptyMap()); + creatorMap.put("realm", "auth realm"); + source.put("creator", creatorMap); + GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 1L, true, + BytesReference.bytes(JsonXContent.contentBuilder().map(source)), Collections.emptyMap())); + listener.onResponse(getResponse); + } else { + listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), -1L, false, null, + Collections.emptyMap()))); + } + return Void.TYPE; + }).when(client).get(any(GetRequest.class), any(ActionListener.class)); + + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + threadContext.putHeader("Authorization", headerValue); + final Authentication authentication = authenticateBlocking("_action", message, null); + assertThat(authentication.getUser().principal(), is("johndoe")); + } + } + + public void testExpiredApiKey() { + final String id = randomAlphaOfLength(12); + final String key = UUIDs.randomBase64UUID(random()); + final String headerValue = "ApiKey " + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); + doAnswer(invocationOnMock -> { + final GetRequest request = (GetRequest) invocationOnMock.getArguments()[0]; + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + if (request.id().equals(id)) { + final Map source = new HashMap<>(); + source.put("doc_type", "api_key"); + source.put("creation_time", Instant.now().minus(5L, ChronoUnit.HOURS).toEpochMilli()); + source.put("expiration_time", Instant.now().minus(1L, ChronoUnit.HOURS).toEpochMilli()); + source.put("api_key_hash", new String(Hasher.BCRYPT4.hash(new SecureString(key.toCharArray())))); + source.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); + source.put("name", "my api key for testApiKeyAuth"); + Map creatorMap = new HashMap<>(); + creatorMap.put("principal", "johndoe"); + creatorMap.put("metadata", Collections.emptyMap()); + creatorMap.put("realm", "auth realm"); + source.put("creator", creatorMap); + GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 1L, true, + BytesReference.bytes(JsonXContent.contentBuilder().map(source)), Collections.emptyMap())); + listener.onResponse(getResponse); + } else { + listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), -1L, false, null, + Collections.emptyMap()))); + } + return Void.TYPE; + }).when(client).get(any(GetRequest.class), any(ActionListener.class)); + + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + threadContext.putHeader("Authorization", headerValue); + ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> authenticateBlocking("_action", message, null)); + assertThat(e.getMessage(), containsString("api key is expired")); + assertEquals(RestStatus.UNAUTHORIZED, e.status()); + } + } + private static class InternalMessage extends TransportMessage { } From 476879a33fd956a61fd91254606bb9499f7b5780 Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Thu, 6 Dec 2018 08:58:14 -0700 Subject: [PATCH 06/26] Implement lookup of permissions for API keys (#35970) This change implements a lookup of permissions for API keys when a request moves to authorization. In order to support this, the authentication of an API key will attach values as metadata on the authentication result. The values attached will include the source of the role descriptors. The authentication service will then copy this metadata to the authentication object and set the authentication type to API_KEY. The authorization service will use the authentication type to make a decision on how the roles should be obtained. --- .../core/security/authc/Authentication.java | 60 ++++-- .../resources/security-index-template.json | 2 +- .../xpack/security/Security.java | 2 +- .../TransportGetUserPrivilegesAction.java | 5 +- .../user/TransportHasPrivilegesAction.java | 5 +- .../xpack/security/authc/ApiKeyService.java | 113 ++++++++++- .../security/authc/AuthenticationService.java | 4 +- .../security/authz/AuthorizationService.java | 38 ++-- .../security/authz/AuthorizationUtils.java | 5 +- .../authz/store/CompositeRolesStore.java | 5 + .../filter/SecurityActionFilterTests.java | 16 +- .../TransportHasPrivilegesActionTests.java | 4 +- .../security/authc/ApiKeyIntegTests.java | 43 +++- .../security/authc/ApiKeyServiceTests.java | 112 ++++++++++- .../authc/AuthenticationServiceTests.java | 2 +- .../authz/AuthorizationServiceTests.java | 183 +++++++++++------- .../authz/IndicesAndAliasesResolverTests.java | 9 +- .../transport/ServerTransportFilterTests.java | 12 +- 18 files changed, 477 insertions(+), 143 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java index b9dbe0a948ff2..1c6234a6e4143 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.util.Base64; +import java.util.Collections; +import java.util.Map; import java.util.Objects; // TODO(hub-cap) Clean this up after moving User over - This class can re-inherit its field AUTHENTICATION_KEY in AuthenticationField. @@ -28,16 +30,25 @@ public class Authentication implements ToXContentObject { private final RealmRef authenticatedBy; private final RealmRef lookedUpBy; private final Version version; + private final AuthenticationType type; + private final Map metadata; public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy) { this(user, authenticatedBy, lookedUpBy, Version.CURRENT); } public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy, Version version) { + this(user, authenticatedBy, lookedUpBy, version, AuthenticationType.REALM, Collections.emptyMap()); + } + + public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy, Version version, + AuthenticationType type, Map metadata) { this.user = Objects.requireNonNull(user); this.authenticatedBy = Objects.requireNonNull(authenticatedBy); this.lookedUpBy = lookedUpBy; this.version = version; + this.type = type; + this.metadata = metadata; } public Authentication(StreamInput in) throws IOException { @@ -49,6 +60,13 @@ public Authentication(StreamInput in) throws IOException { this.lookedUpBy = null; } this.version = in.getVersion(); + if (in.getVersion().onOrAfter(Version.V_7_0_0)) { // TODO change to V6_6 after backport + type = AuthenticationType.values()[in.readVInt()]; + metadata = in.readMap(); + } else { + type = AuthenticationType.REALM; + metadata = Collections.emptyMap(); + } } public User getUser() { @@ -67,8 +85,15 @@ public Version getVersion() { return version; } - public static Authentication readFromContext(ThreadContext ctx) - throws IOException, IllegalArgumentException { + public AuthenticationType getAuthenticationType() { + return type; + } + + public Map getMetadata() { + return metadata; + } + + public static Authentication readFromContext(ThreadContext ctx) throws IOException, IllegalArgumentException { Authentication authentication = ctx.getTransient(AuthenticationField.AUTHENTICATION_KEY); if (authentication != null) { assert ctx.getHeader(AuthenticationField.AUTHENTICATION_KEY) != null; @@ -107,8 +132,7 @@ public static Authentication decode(String header) throws IOException { * Writes the authentication to the context. There must not be an existing authentication in the context and if there is an * {@link IllegalStateException} will be thrown */ - public void writeToContext(ThreadContext ctx) - throws IOException, IllegalArgumentException { + public void writeToContext(ThreadContext ctx) throws IOException, IllegalArgumentException { ensureContextDoesNotContainAuthentication(ctx); String header = encode(); ctx.putTransient(AuthenticationField.AUTHENTICATION_KEY, this); @@ -141,28 +165,28 @@ public void writeTo(StreamOutput out) throws IOException { } else { out.writeBoolean(false); } + if (out.getVersion().onOrAfter(Version.V_7_0_0)) { // TODO change to V6_6 after backport + out.writeVInt(type.ordinal()); + out.writeMap(metadata); + } } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - Authentication that = (Authentication) o; - - if (!user.equals(that.user)) return false; - if (!authenticatedBy.equals(that.authenticatedBy)) return false; - if (lookedUpBy != null ? !lookedUpBy.equals(that.lookedUpBy) : that.lookedUpBy != null) return false; - return version.equals(that.version); + return user.equals(that.user) && + authenticatedBy.equals(that.authenticatedBy) && + Objects.equals(lookedUpBy, that.lookedUpBy) && + version.equals(that.version) && + type == that.type && + metadata.equals(that.metadata); } @Override public int hashCode() { - int result = user.hashCode(); - result = 31 * result + authenticatedBy.hashCode(); - result = 31 * result + (lookedUpBy != null ? lookedUpBy.hashCode() : 0); - result = 31 * result + version.hashCode(); - return result; + return Objects.hash(user, authenticatedBy, lookedUpBy, version, type, metadata); } @Override @@ -246,5 +270,11 @@ public int hashCode() { return result; } } + + public enum AuthenticationType { + REALM, + API_KEY, + TOKEN + } } diff --git a/x-pack/plugin/core/src/main/resources/security-index-template.json b/x-pack/plugin/core/src/main/resources/security-index-template.json index 073db1830598f..45d9b96d69fdf 100644 --- a/x-pack/plugin/core/src/main/resources/security-index-template.json +++ b/x-pack/plugin/core/src/main/resources/security-index-template.json @@ -156,7 +156,7 @@ }, "role_descriptors" : { "type" : "object", - "dynamic" : true + "enabled": false }, "version" : { "type" : "integer" 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 045690264bb0a..3f567abb5701e 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 @@ -479,7 +479,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste // minimal getLicenseState().addListener(allRolesStore::invalidateAll); final AuthorizationService authzService = new AuthorizationService(settings, allRolesStore, clusterService, - auditTrailService, failureHandler, threadPool, anonymousUser); + auditTrailService, failureHandler, threadPool, anonymousUser, apiKeyService); components.add(nativeRolesStore); // used by roles actions components.add(reservedRolesStore); // used by roles actions components.add(allRolesStore); // for SecurityFeatureSet and clear roles cache diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUserPrivilegesAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUserPrivilegesAction.java index 518c9cb25a01a..b27249852879a 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUserPrivilegesAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUserPrivilegesAction.java @@ -60,13 +60,14 @@ public TransportGetUserPrivilegesAction(ThreadPool threadPool, TransportService protected void doExecute(Task task, GetUserPrivilegesRequest request, ActionListener listener) { final String username = request.username(); - final User user = Authentication.getAuthentication(threadPool.getThreadContext()).getUser(); + final Authentication authentication = Authentication.getAuthentication(threadPool.getThreadContext()); + final User user = authentication.getUser(); if (user.principal().equals(username) == false) { listener.onFailure(new IllegalArgumentException("users may only list the privileges of their own account")); return; } - authorizationService.roles(user, ActionListener.wrap( + authorizationService.roles(user, authentication, ActionListener.wrap( role -> listener.onResponse(buildResponseObject(role)), listener::onFailure)); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesAction.java index 20fa9f522e710..4a15723a122b6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesAction.java @@ -68,13 +68,14 @@ public TransportHasPrivilegesAction(ThreadPool threadPool, TransportService tran protected void doExecute(Task task, HasPrivilegesRequest request, ActionListener listener) { final String username = request.username(); - final User user = Authentication.getAuthentication(threadPool.getThreadContext()).getUser(); + final Authentication authentication = Authentication.getAuthentication(threadPool.getThreadContext()); + final User user = authentication.getUser(); if (user.principal().equals(username) == false) { listener.onFailure(new IllegalArgumentException("users may only check the privileges of their own account")); return; } - authorizationService.roles(user, ActionListener.wrap( + authorizationService.roles(user, authentication, ActionListener.wrap( role -> resolveApplicationPrivileges(request, ActionListener.wrap( applicationPrivilegeLookup -> checkPrivileges(request, role, applicationPrivilegeLookup, listener), listener::onFailure)), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 3673bba69a2e0..808817c4bc3eb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -19,29 +19,41 @@ import org.elasticsearch.common.CharArrays; import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.logging.DeprecationLogger; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.DeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache; +import org.elasticsearch.xpack.core.security.authz.permission.Role; import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import javax.crypto.SecretKeyFactory; import java.io.Closeable; import java.io.IOException; +import java.io.UncheckedIOException; import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; import java.util.Arrays; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -55,7 +67,12 @@ public class ApiKeyService { private static final Logger logger = LogManager.getLogger(ApiKeyService.class); + private static final DeprecationLogger deprecationLogger = new DeprecationLogger(logger); private static final String TYPE = "doc"; + static final String API_KEY_ID_KEY = "_security_api_key_id"; + static final String API_KEY_ROLE_DESCRIPTORS_KEY = "_security_api_key_role_descriptors"; + static final String API_KEY_ROLE_KEY = "_security_api_key_role"; + public static final Setting PASSWORD_HASHING_ALGORITHM = new Setting<>( "xpack.security.authc.api_key_hashing.algorithm", "pbkdf2", Function.identity(), (v, s) -> { if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) { @@ -126,8 +143,12 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ } } - builder.array("role_descriptors", request.getRoleDescriptors()) - .field("name", request.getName()) + builder.startObject("role_descriptors"); + for (RoleDescriptor descriptor : request.getRoleDescriptors()) { + builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + } + builder.endObject(); + builder.field("name", request.getName()) .field("version", version.id) .startObject("creator") .field("principal", authentication.getUser().principal()) @@ -174,7 +195,8 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListenerwrap(response -> { if (response.isExists()) { try (ApiKeyCredentials ignore = credentials) { - validateApiKeyCredentials(response.getSource(), credentials, clock, listener); + final Map source = response.getSource(); + validateApiKeyCredentials(source, credentials, clock, listener); } } else { credentials.close(); @@ -194,6 +216,56 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener listener) { + if (authentication.getAuthenticationType() != Authentication.AuthenticationType.API_KEY) { + throw new IllegalStateException("authentication type must be api key but is " + authentication.getAuthenticationType()); + } + + final Map metadata = authentication.getMetadata(); + final String apiKeyId = (String) metadata.get(API_KEY_ID_KEY); + final String contextKeyId = threadContext.getTransient(API_KEY_ID_KEY); + if (apiKeyId.equals(contextKeyId)) { + final Role preBuiltRole = threadContext.getTransient(API_KEY_ROLE_KEY); + if (preBuiltRole != null) { + listener.onResponse(preBuiltRole); + return; + } + } else if (contextKeyId != null) { + throw new IllegalStateException("authentication api key id [" + apiKeyId + "] does not match context value [" + + contextKeyId + "]"); + } + + final Map roleDescriptors = (Map) metadata.get(API_KEY_ROLE_DESCRIPTORS_KEY); + final List roleDescriptorList = roleDescriptors.entrySet().stream() + .map(entry -> { + final String name = entry.getKey(); + final Map rdMap = (Map) entry.getValue(); + try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent())) { + builder.map(rdMap); + try (XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, + new ApiKeyLoggingDeprecationHandler(deprecationLogger, apiKeyId), + BytesReference.bytes(builder).streamInput())) { + return RoleDescriptor.parse(name, parser, false); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }).collect(Collectors.toList()); + + rolesStore.buildRoleFromDescriptors(roleDescriptorList, fieldPermissionsCache, ActionListener.wrap(role -> { + threadContext.putTransient(API_KEY_ID_KEY, apiKeyId); + threadContext.putTransient(API_KEY_ROLE_KEY, role); + listener.onResponse(role); + }, listener::onFailure)); + + } + /** * Validates the ApiKey using the source map * @param source the source map from a get of the ApiKey document @@ -214,13 +286,13 @@ static void validateApiKeyCredentials(Map source, ApiKeyCredenti final Map creator = Objects.requireNonNull((Map) source.get("creator")); final String principal = Objects.requireNonNull((String) creator.get("principal")); final Map metadata = (Map) creator.get("metadata"); - final List> roleDescriptors = (List>) source.get("role_descriptors"); - final String[] roleNames = roleDescriptors.stream() - .map(rdSource -> (String) rdSource.get("name")) - .collect(Collectors.toList()) - .toArray(Strings.EMPTY_ARRAY); + final Map roleDescriptors = (Map) source.get("role_descriptors"); + final String[] roleNames = roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY); final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); - listener.onResponse(AuthenticationResult.success(apiKeyUser)); + final Map authResultMetadata = new HashMap<>(); + authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors); + authResultMetadata.put(API_KEY_ID_KEY, credentials.getId()); + listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata)); } else { listener.onResponse(AuthenticationResult.terminate("api key is expired", null)); } @@ -310,4 +382,27 @@ public void close() { key.close(); } } + + private static class ApiKeyLoggingDeprecationHandler implements DeprecationHandler { + + private final DeprecationLogger deprecationLogger; + private final String apiKeyId; + + private ApiKeyLoggingDeprecationHandler(DeprecationLogger logger, String apiKeyId) { + this.deprecationLogger = logger; + this.apiKeyId = apiKeyId; + } + + @Override + public void usedDeprecatedName(String usedName, String modernName) { + deprecationLogger.deprecated("Deprecated field [{}] used in api key [{}], expected [{}] instead", + usedName, apiKeyId, modernName); + } + + @Override + public void usedDeprecatedField(String usedName, String replacedWith) { + deprecationLogger.deprecated("Deprecated field [{}] used in api key [{}], replaced by [{}]", + usedName, apiKeyId, replacedWith); + } + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 62c8f006f05cf..06b8db33eaa8f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.apache.logging.log4j.util.Supplier; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.common.Nullable; @@ -205,7 +206,8 @@ private void checkForApiKey() { if (authResult.isAuthenticated()) { final User user = authResult.getUser(); authenticatedBy = new RealmRef("_es_api_key", "_es_api_key", nodeName); - writeAuthToContext(new Authentication(user, authenticatedBy, null)); + writeAuthToContext(new Authentication(user, authenticatedBy, null, Version.CURRENT, + Authentication.AuthenticationType.API_KEY, authResult.getMetadata())); } else if (authResult.getStatus() == AuthenticationResult.Status.TERMINATE) { Exception e = (authResult.getException() != null) ? authResult.getException() : Exceptions.authenticationError(authResult.getMessage()); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index 35a2f5340492d..ae5dd7e36cf59 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -64,6 +64,7 @@ import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; +import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authz.IndicesAndAliasesResolver.ResolvedIndices; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; @@ -105,12 +106,13 @@ public class AuthorizationService { private final ThreadContext threadContext; private final AnonymousUser anonymousUser; private final FieldPermissionsCache fieldPermissionsCache; + private final ApiKeyService apiKeyService; private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, ClusterService clusterService, AuditTrailService auditTrail, AuthenticationFailureHandler authcFailureHandler, - ThreadPool threadPool, AnonymousUser anonymousUser) { + ThreadPool threadPool, AnonymousUser anonymousUser, ApiKeyService apiKeyService) { this.rolesStore = rolesStore; this.clusterService = clusterService; this.auditTrail = auditTrail; @@ -121,6 +123,7 @@ public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, C this.isAnonymousEnabled = AnonymousUser.isAnonymousEnabled(settings); this.anonymousAuthzExceptionEnabled = ANONYMOUS_AUTHORIZATION_EXCEPTION_SETTING.get(settings); this.fieldPermissionsCache = new FieldPermissionsCache(settings); + this.apiKeyService = apiKeyService; } /** @@ -456,7 +459,7 @@ private void putTransientIfNonExisting(String key, Object value) { } } - public void roles(User user, ActionListener roleActionListener) { + public void roles(User user, Authentication authentication, ActionListener roleActionListener) { // we need to special case the internal users in this method, if we apply the anonymous roles to every user including these system // user accounts then we run into the chance of a deadlock because then we need to get a role that we may be trying to get as the // internal user. The SystemUser is special cased as it has special privileges to execute internal actions and should never be @@ -475,21 +478,26 @@ public void roles(User user, ActionListener roleActionListener) { return; } - Set roleNames = new HashSet<>(); - Collections.addAll(roleNames, user.roles()); - if (isAnonymousEnabled && anonymousUser.equals(user) == false) { - if (anonymousUser.roles().length == 0) { - throw new IllegalStateException("anonymous is only enabled when the anonymous user has roles"); + final Authentication.AuthenticationType authType = authentication.getAuthenticationType(); + if (authType == Authentication.AuthenticationType.API_KEY) { + apiKeyService.getRoleForApiKey(authentication, threadContext, rolesStore, fieldPermissionsCache, roleActionListener); + } else { + Set roleNames = new HashSet<>(); + Collections.addAll(roleNames, user.roles()); + if (isAnonymousEnabled && anonymousUser.equals(user) == false) { + if (anonymousUser.roles().length == 0) { + throw new IllegalStateException("anonymous is only enabled when the anonymous user has roles"); + } + Collections.addAll(roleNames, anonymousUser.roles()); } - Collections.addAll(roleNames, anonymousUser.roles()); - } - if (roleNames.isEmpty()) { - roleActionListener.onResponse(Role.EMPTY); - } else if (roleNames.contains(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName())) { - roleActionListener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); - } else { - rolesStore.roles(roleNames, fieldPermissionsCache, roleActionListener); + if (roleNames.isEmpty()) { + roleActionListener.onResponse(Role.EMPTY); + } else if (roleNames.contains(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName())) { + roleActionListener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); + } else { + rolesStore.roles(roleNames, fieldPermissionsCache, roleActionListener); + } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java index 193e3bdb3bd5c..3b141a43b4b80 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationUtils.java @@ -152,9 +152,10 @@ public void authorize(AuthorizationService service) { setUserRoles(null); // we can inform the listener immediately - nothing to fetch for us on system user setRunAsRoles(null); } else { - service.roles(authentication.getUser().authenticatedUser(), ActionListener.wrap(this::setUserRoles, listener::onFailure)); + service.roles(authentication.getUser().authenticatedUser(), authentication, + ActionListener.wrap(this::setUserRoles, listener::onFailure)); if (authentication.getUser().isRunAs()) { - service.roles(authentication.getUser(), ActionListener.wrap(this::setRunAsRoles, listener::onFailure)); + service.roles(authentication.getUser(), authentication, ActionListener.wrap(this::setRunAsRoles, listener::onFailure)); } else { setRunAsRoles(null); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index aa77217eb1bb7..b4c2a34fc747c 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -241,6 +241,11 @@ private String names(Collection descriptors) { return descriptors.stream().map(RoleDescriptor::getName).collect(Collectors.joining(",")); } + public void buildRoleFromDescriptors(Collection roleDescriptors, FieldPermissionsCache fieldPermissionsCache, + ActionListener listener) { + buildRoleFromDescriptors(roleDescriptors, fieldPermissionsCache, privilegeStore, listener); + } + public static void buildRoleFromDescriptors(Collection roleDescriptors, FieldPermissionsCache fieldPermissionsCache, NativePrivilegeStore privilegeStore, ActionListener listener) { if (roleDescriptors.isEmpty()) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/filter/SecurityActionFilterTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/filter/SecurityActionFilterTests.java index b14ed2e4848b8..80d5e3f85281e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/filter/SecurityActionFilterTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/filter/SecurityActionFilterTests.java @@ -103,10 +103,10 @@ public void testApply() throws Exception { final Role empty = Role.EMPTY; doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; callback.onResponse(empty); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); filter.apply(task, "_action", request, listener, chain); verify(authzService).authorize(authentication, "_action", request, empty, null); verify(chain).proceed(eq(task), eq("_action"), eq(request), isA(ContextPreservingActionListener.class)); @@ -130,11 +130,11 @@ public void testApplyRestoresThreadContext() throws Exception { final Role empty = Role.EMPTY; doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; assertEquals(authentication, threadContext.getTransient(AuthenticationField.AUTHENTICATION_KEY)); callback.onResponse(empty); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); assertNull(threadContext.getTransient(AuthenticationField.AUTHENTICATION_KEY)); filter.apply(task, "_action", request, listener, chain); @@ -201,10 +201,10 @@ public void testApplyDestructiveOperations() throws Exception { final Role empty = Role.EMPTY; doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; callback.onResponse(empty); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); filter.apply(task, action, request, listener, chain); if (failDestructiveOperations) { verify(listener).onFailure(isA(IllegalArgumentException.class)); @@ -231,10 +231,10 @@ public void testActionProcessException() throws Exception { }).when(authcService).authenticate(eq("_action"), eq(request), eq(SystemUser.INSTANCE), any(ActionListener.class)); doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; callback.onResponse(Role.EMPTY); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); doThrow(exception).when(authzService).authorize(eq(authentication), eq("_action"), eq(request), any(Role.class), any(Role.class)); filter.apply(task, "_action", request, listener, chain); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesActionTests.java index 2da066b56eee1..d4d36d292f581 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesActionTests.java @@ -86,10 +86,10 @@ public void setup() { AuthorizationService authorizationService = mock(AuthorizationService.class); Mockito.doAnswer(invocationOnMock -> { - ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; listener.onResponse(role); return null; - }).when(authorizationService).roles(eq(user), any(ActionListener.class)); + }).when(authorizationService).roles(eq(user), any(Authentication.class), any(ActionListener.class)); applicationPrivileges = new ArrayList<>(); NativePrivilegeStore privilegeStore = mock(NativePrivilegeStore.class); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 8e578a0f1e2cc..59200d2739ea2 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -6,9 +6,12 @@ package org.elasticsearch.xpack.security.authc; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.SecurityIntegTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.test.SecuritySettingsSourceField; @@ -17,11 +20,14 @@ import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.client.SecurityClient; +import org.elasticsearch.xpack.security.transport.filter.IPFilter; import org.junit.After; import org.junit.Before; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Base64; import java.util.Collections; import java.util.concurrent.TimeUnit; @@ -56,7 +62,7 @@ public void testCreateApiKey() { UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); SecurityClient securityClient = new SecurityClient(client); - CreateApiKeyResponse response = securityClient.prepareCreateApiKey() + final CreateApiKeyResponse response = securityClient.prepareCreateApiKey() .setName("test key") .setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L))) .setRoleDescriptors(Collections.singletonList(descriptor)) @@ -69,12 +75,33 @@ public void testCreateApiKey() { final long daysBetween = ChronoUnit.DAYS.between(start, expiration); assertThat(daysBetween, is(7L)); - // simple one - response = securityClient.prepareCreateApiKey().setName("simple").get(); - assertEquals("simple", response.getName()); - assertNotNull(response.getId()); - assertNotNull(response.getKey()); - assertThat(response.getId(), not(containsString(new String(response.getKey().getChars())))); - assertNull(response.getExpiration()); + // create simple api key + final CreateApiKeyResponse simple = securityClient.prepareCreateApiKey().setName("simple").get(); + assertEquals("simple", simple.getName()); + assertNotNull(simple.getId()); + assertNotNull(simple.getKey()); + assertThat(simple.getId(), not(containsString(new String(simple.getKey().getChars())))); + assertNull(simple.getExpiration()); + + // use the first ApiKey for authorized action + final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString( + (response.getId() + ":" + response.getKey().toString()).getBytes(StandardCharsets.UTF_8)); + ClusterHealthResponse healthResponse = client() + .filterWithHeader(Collections.singletonMap("Authorization", "ApiKey " + base64ApiKeyKeyValue)) + .admin() + .cluster() + .prepareHealth() + .get(); + assertFalse(healthResponse.isTimedOut()); + + // use the first ApiKey for an unauthorized action + ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> + client().filterWithHeader(Collections.singletonMap("Authorization", "ApiKey " + base64ApiKeyKeyValue)) + .admin() + .cluster() + .prepareUpdateSettings().setTransientSettings(Settings.builder().put(IPFilter.IP_FILTER_ENABLED_SETTING.getKey(), true)) + .get()); + assertThat(e.getMessage(), containsString("unauthorized")); + assertThat(e.status(), is(RestStatus.FORBIDDEN)); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index b41c9dd9dfd98..4f75f1158a968 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -6,27 +6,66 @@ package org.elasticsearch.xpack.security.authc; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.test.ClusterServiceUtils; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; +import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache; +import org.elasticsearch.xpack.core.security.authz.permission.Role; +import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; +import org.junit.After; +import org.junit.Before; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.temporal.ChronoUnit; import java.util.Base64; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.is; public class ApiKeyServiceTests extends ESTestCase { + private ThreadPool threadPool; + + @Before + public void createThreadPool() { + threadPool = new TestThreadPool("api key service tests"); + } + + @After + public void stopThreadPool() { + terminate(threadPool); + } + public void testGetCredentialsFromThreadContext() { ThreadContext threadContext = new ThreadContext(Settings.EMPTY); assertNull(ApiKeyService.getCredentialsFromHeader(threadContext)); @@ -69,7 +108,7 @@ public void testValidateApiKey() throws Exception { Map sourceMap = new HashMap<>(); sourceMap.put("api_key_hash", new String(hash)); - sourceMap.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); + sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all"))); Map creatorMap = new HashMap<>(); creatorMap.put("principal", "test_user"); creatorMap.put("metadata", Collections.emptyMap()); @@ -111,4 +150,75 @@ public void testValidateApiKey() throws Exception { assertNotNull(result); assertFalse(result.isAuthenticated()); } + + public void testGetRolesForApiKeyFromContext() throws Exception { + final String keyId = randomAlphaOfLength(12); + final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, + Version.CURRENT, AuthenticationType.API_KEY, Collections.singletonMap(ApiKeyService.API_KEY_ID_KEY, keyId)); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ApiKeyService.API_KEY_ID_KEY, keyId); + threadContext.putTransient(ApiKeyService.API_KEY_ROLE_KEY, ReservedRolesStore.SUPERUSER_ROLE); + ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, + ClusterServiceUtils.createClusterService(threadPool)); + PlainActionFuture roleFuture = new PlainActionFuture<>(); + service.getRoleForApiKey(authentication, threadContext, mock(CompositeRolesStore.class), new FieldPermissionsCache(Settings.EMPTY), + roleFuture); + Role role = roleFuture.get(); + assertEquals(ReservedRolesStore.SUPERUSER_ROLE, role); + } + + public void testGetRolesForApiKeyIncorrectKeyId() { + final String keyId = randomAlphaOfLength(12); + final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, + Version.CURRENT, AuthenticationType.API_KEY, Collections.singletonMap(ApiKeyService.API_KEY_ID_KEY, keyId)); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ApiKeyService.API_KEY_ID_KEY, randomAlphaOfLength(11)); + threadContext.putTransient(ApiKeyService.API_KEY_ROLE_KEY, ReservedRolesStore.SUPERUSER_ROLE); + ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, + ClusterServiceUtils.createClusterService(threadPool)); + PlainActionFuture roleFuture = new PlainActionFuture<>(); + IllegalStateException ise = expectThrows(IllegalStateException.class, () -> service.getRoleForApiKey(authentication, threadContext, + mock(CompositeRolesStore.class),new FieldPermissionsCache(Settings.EMPTY), roleFuture)); + assertThat(ise.getMessage(), containsString(keyId)); + assertThat(ise.getMessage(), containsString(threadContext.getTransient(ApiKeyService.API_KEY_ID_KEY))); + } + + public void testGetRolesForApiKeyNotInContext() throws Exception { + Map superUserRdMap; + try (XContentBuilder builder = JsonXContent.contentBuilder()) { + superUserRdMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), + BytesReference.bytes(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR + .toXContent(builder, ToXContent.EMPTY_PARAMS, true)) + .streamInput(), + false); + } + Map authMetadata = new HashMap<>(); + authMetadata.put(ApiKeyService.API_KEY_ID_KEY, randomAlphaOfLength(12)); + authMetadata.put(ApiKeyService.API_KEY_ROLE_DESCRIPTORS_KEY, + Collections.singletonMap(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName(), superUserRdMap)); + + final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, + Version.CURRENT, AuthenticationType.API_KEY, authMetadata); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, + ClusterServiceUtils.createClusterService(threadPool)); + CompositeRolesStore rolesStore = mock(CompositeRolesStore.class); + doAnswer(invocationOnMock -> { + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + Collection descriptors = (Collection) invocationOnMock.getArguments()[0]; + if (descriptors.size() != 1) { + listener.onFailure(new IllegalStateException("descriptors was empty!")); + } else if (descriptors.iterator().next().getName().equals("superuser")) { + listener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); + } else { + listener.onFailure(new IllegalStateException("unexpected role name " + descriptors.iterator().next().getName())); + } + return Void.TYPE; + }).when(rolesStore).buildRoleFromDescriptors(any(Collection.class), any(FieldPermissionsCache.class), any(ActionListener.class)); + + PlainActionFuture roleFuture = new PlainActionFuture<>(); + service.getRoleForApiKey(authentication, threadContext, rolesStore, new FieldPermissionsCache(Settings.EMPTY), roleFuture); + Role role = roleFuture.get(); + assertThat(role.names(), arrayContaining("superuser")); + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index c00542971de58..b53736acc0c65 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -1094,7 +1094,7 @@ public void testApiKeyAuth() { source.put("doc_type", "api_key"); source.put("creation_time", Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); source.put("api_key_hash", new String(Hasher.BCRYPT4.hash(new SecureString(key.toCharArray())))); - source.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); + source.put("role_descriptors", Collections.singletonMap("api key role", Collections.singletonMap("cluster", "all"))); source.put("name", "my api key for testApiKeyAuth"); Map creatorMap = new HashMap<>(); creatorMap.put("principal", "johndoe"); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java index 2a9d832f0f012..797a7694fb607 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java @@ -102,6 +102,7 @@ import org.elasticsearch.xpack.core.security.action.user.PutUserAction; import org.elasticsearch.xpack.core.security.action.user.UserRequest; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.DefaultAuthenticationFailureHandler; import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; @@ -126,6 +127,7 @@ import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; +import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore; @@ -134,6 +136,7 @@ import org.junit.Before; import org.mockito.Mockito; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -161,6 +164,7 @@ import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.startsWith; 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.times; @@ -177,6 +181,7 @@ public class AuthorizationServiceTests extends ESTestCase { private ThreadPool threadPool; private Map roleMap = new HashMap<>(); private CompositeRolesStore rolesStore; + private ApiKeyService apiKeyService; @SuppressWarnings("unchecked") @Before @@ -226,8 +231,10 @@ public void setup() { } return Void.TYPE; }).when(rolesStore).roles(any(Set.class), any(FieldPermissionsCache.class), any(ActionListener.class)); - authorizationService = new AuthorizationService(settings, rolesStore, clusterService, - auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings)); + apiKeyService = mock(ApiKeyService.class); + authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), + apiKeyService); } private void authorize(Authentication authentication, String action, TransportRequest request) { @@ -241,7 +248,7 @@ private void authorize(Authentication authentication, String action, TransportRe future.actionGet(); } - public void testActionsForSystemUserIsAuthorized() { + public void testActionsForSystemUserIsAuthorized() throws IOException { final TransportRequest request = mock(TransportRequest.class); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -258,7 +265,7 @@ public void testActionsForSystemUserIsAuthorized() { verifyNoMoreInteractions(auditTrail); } - public void testIndicesActionsForSystemUserWhichAreNotAuthorized() { + public void testIndicesActionsForSystemUserWhichAreNotAuthorized() throws IOException { final TransportRequest request = mock(TransportRequest.class); final Authentication authentication = createAuthentication(SystemUser.INSTANCE); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -269,7 +276,7 @@ public void testIndicesActionsForSystemUserWhichAreNotAuthorized() { verifyNoMoreInteractions(auditTrail); } - public void testClusterAdminActionsForSystemUserWhichAreNotAuthorized() { + public void testClusterAdminActionsForSystemUserWhichAreNotAuthorized() throws IOException { final TransportRequest request = mock(TransportRequest.class); final Authentication authentication = createAuthentication(SystemUser.INSTANCE); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -281,7 +288,7 @@ public void testClusterAdminActionsForSystemUserWhichAreNotAuthorized() { verifyNoMoreInteractions(auditTrail); } - public void testClusterAdminSnapshotStatusActionForSystemUserWhichIsNotAuthorized() { + public void testClusterAdminSnapshotStatusActionForSystemUserWhichIsNotAuthorized() throws IOException { final TransportRequest request = mock(TransportRequest.class); final Authentication authentication = createAuthentication(SystemUser.INSTANCE); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -293,7 +300,7 @@ public void testClusterAdminSnapshotStatusActionForSystemUserWhichIsNotAuthorize verifyNoMoreInteractions(auditTrail); } - public void testAuthorizeUsingConditionalPrivileges() { + public void testAuthorizeUsingConditionalPrivileges() throws IOException { final DeletePrivilegesRequest request = new DeletePrivilegesRequest(); final Authentication authentication = createAuthentication(new User("user1", "role1")); @@ -313,7 +320,7 @@ public void testAuthorizeUsingConditionalPrivileges() { verifyNoMoreInteractions(auditTrail); } - public void testAuthorizationDeniedWhenConditionalPrivilegesDoNotMatch() { + public void testAuthorizationDeniedWhenConditionalPrivilegesDoNotMatch() throws IOException { final DeletePrivilegesRequest request = new DeletePrivilegesRequest(); final Authentication authentication = createAuthentication(new User("user1", "role1")); @@ -335,7 +342,7 @@ public void testAuthorizationDeniedWhenConditionalPrivilegesDoNotMatch() { verifyNoMoreInteractions(auditTrail); } - public void testNoRolesCausesDenial() { + public void testNoRolesCausesDenial() throws IOException { final TransportRequest request = new SearchRequest(); final Authentication authentication = createAuthentication(new User("test user")); mockEmptyMetaData(); @@ -347,7 +354,7 @@ public void testNoRolesCausesDenial() { verifyNoMoreInteractions(auditTrail); } - public void testUserWithNoRolesCanPerformRemoteSearch() { + public void testUserWithNoRolesCanPerformRemoteSearch() throws IOException { SearchRequest request = new SearchRequest(); request.indices("other_cluster:index1", "*_cluster:index2", "other_cluster:other_*"); final Authentication authentication = createAuthentication(new User("test user")); @@ -363,7 +370,7 @@ public void testUserWithNoRolesCanPerformRemoteSearch() { * while the referenced index _looks_ like a remote index, the remote cluster name has not * been defined, so it is actually a local index and access should be denied */ - public void testUserWithNoRolesCannotPerformLocalSearch() { + public void testUserWithNoRolesCannotPerformLocalSearch() throws IOException { SearchRequest request = new SearchRequest(); request.indices("no_such_cluster:index"); final Authentication authentication = createAuthentication(new User("test user")); @@ -380,7 +387,7 @@ public void testUserWithNoRolesCannotPerformLocalSearch() { * This test mimics {@link #testUserWithNoRolesCannotPerformLocalSearch()} but includes * both local and remote indices, including wildcards */ - public void testUserWithNoRolesCanPerformMultiClusterSearch() { + public void testUserWithNoRolesCanPerformMultiClusterSearch() throws IOException { SearchRequest request = new SearchRequest(); request.indices("local_index", "wildcard_*", "other_cluster:remote_index", "*:foo?"); final Authentication authentication = createAuthentication(new User("test user")); @@ -393,7 +400,7 @@ public void testUserWithNoRolesCanPerformMultiClusterSearch() { verifyNoMoreInteractions(auditTrail); } - public void testUserWithNoRolesCannotSql() { + public void testUserWithNoRolesCannotSql() throws IOException { TransportRequest request = new SqlQueryRequest(); Authentication authentication = createAuthentication(new User("test user")); mockEmptyMetaData(); @@ -408,7 +415,7 @@ public void testUserWithNoRolesCannotSql() { * Verifies that the behaviour tested in {@link #testUserWithNoRolesCanPerformRemoteSearch} * does not work for requests that are not remote-index-capable. */ - public void testRemoteIndicesOnlyWorkWithApplicableRequestTypes() { + public void testRemoteIndicesOnlyWorkWithApplicableRequestTypes() throws IOException { DeleteIndexRequest request = new DeleteIndexRequest(); request.indices("other_cluster:index1", "other_cluster:index2"); final Authentication authentication = createAuthentication(new User("test user")); @@ -421,7 +428,7 @@ public void testRemoteIndicesOnlyWorkWithApplicableRequestTypes() { verifyNoMoreInteractions(auditTrail); } - public void testUnknownRoleCausesDenial() { + public void testUnknownRoleCausesDenial() throws IOException { Tuple tuple = randomFrom(asList( new Tuple<>(SearchAction.NAME, new SearchRequest()), new Tuple<>(IndicesExistsAction.NAME, new IndicesExistsRequest()), @@ -438,7 +445,7 @@ public void testUnknownRoleCausesDenial() { verifyNoMoreInteractions(auditTrail); } - public void testThatNonIndicesAndNonClusterActionIsDenied() { + public void testThatNonIndicesAndNonClusterActionIsDenied() throws IOException { final TransportRequest request = mock(TransportRequest.class); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); final Authentication authentication = createAuthentication(new User("test user", "a_all")); @@ -453,7 +460,7 @@ public void testThatNonIndicesAndNonClusterActionIsDenied() { verifyNoMoreInteractions(auditTrail); } - public void testThatRoleWithNoIndicesIsDenied() { + public void testThatRoleWithNoIndicesIsDenied() throws IOException { @SuppressWarnings("unchecked") Tuple tuple = randomFrom( new Tuple<>(SearchAction.NAME, new SearchRequest()), @@ -474,7 +481,7 @@ public void testThatRoleWithNoIndicesIsDenied() { verifyNoMoreInteractions(auditTrail); } - public void testElasticUserAuthorizedForNonChangePasswordRequestsWhenNotInSetupMode() { + public void testElasticUserAuthorizedForNonChangePasswordRequestsWhenNotInSetupMode() throws IOException { final Authentication authentication = createAuthentication(new ElasticUser(true)); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); final Tuple request = randomCompositeRequest(); @@ -483,7 +490,7 @@ public void testElasticUserAuthorizedForNonChangePasswordRequestsWhenNotInSetupM verify(auditTrail).accessGranted(requestId, authentication, request.v1(), request.v2(), new String[]{ElasticUser.ROLE_NAME}); } - public void testSearchAgainstEmptyCluster() { + public void testSearchAgainstEmptyCluster() throws IOException { RoleDescriptor role = new RoleDescriptor("a_role", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); final Authentication authentication = createAuthentication(new User("test user", "a_all")); @@ -518,7 +525,7 @@ public void testSearchAgainstEmptyCluster() { } } - public void testScrollRelatedRequestsAllowed() { + public void testScrollRelatedRequestsAllowed() throws IOException { RoleDescriptor role = new RoleDescriptor("a_role", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); final Authentication authentication = createAuthentication(new User("test user", "a_all")); @@ -560,7 +567,7 @@ public void testScrollRelatedRequestsAllowed() { verifyNoMoreInteractions(auditTrail); } - public void testAuthorizeIndicesFailures() { + public void testAuthorizeIndicesFailures() throws IOException { TransportRequest request = new GetIndexRequest().indices("b"); ClusterState state = mockEmptyMetaData(); RoleDescriptor role = new RoleDescriptor("a_role", null, @@ -578,7 +585,7 @@ public void testAuthorizeIndicesFailures() { verify(state, times(1)).metaData(); } - public void testCreateIndexWithAliasWithoutPermissions() { + public void testCreateIndexWithAliasWithoutPermissions() throws IOException { CreateIndexRequest request = new CreateIndexRequest("a"); request.alias(new Alias("a2")); ClusterState state = mockEmptyMetaData(); @@ -597,7 +604,7 @@ public void testCreateIndexWithAliasWithoutPermissions() { verify(state, times(1)).metaData(); } - public void testCreateIndexWithAlias() { + public void testCreateIndexWithAlias() throws IOException { CreateIndexRequest request = new CreateIndexRequest("a"); request.alias(new Alias("a2")); ClusterState state = mockEmptyMetaData(); @@ -615,13 +622,13 @@ public void testCreateIndexWithAlias() { verify(state, times(1)).metaData(); } - public void testDenialForAnonymousUser() { + public void testDenialForAnonymousUser() throws IOException { TransportRequest request = new GetIndexRequest().indices("b"); ClusterState state = mockEmptyMetaData(); Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "a_all").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[] { IndicesPrivileges.builder().indices("a").privileges("all").build() }, null); @@ -638,7 +645,7 @@ public void testDenialForAnonymousUser() { verify(state, times(1)).metaData(); } - public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { + public void testDenialForAnonymousUserAuthorizationExceptionDisabled() throws IOException { TransportRequest request = new GetIndexRequest().indices("b"); ClusterState state = mockEmptyMetaData(); Settings settings = Settings.builder() @@ -647,7 +654,8 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { .build(); final Authentication authentication = createAuthentication(new AnonymousUser(settings)); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings)); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), + apiKeyService); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); @@ -663,7 +671,7 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { verify(state, times(1)).metaData(); } - public void testAuditTrailIsRecordedWhenIndexWildcardThrowsError() { + public void testAuditTrailIsRecordedWhenIndexWildcardThrowsError() throws IOException { IndicesOptions options = IndicesOptions.fromOptions(false, false, true, true); TransportRequest request = new GetIndexRequest().indices("not-an-index-*").indicesOptions(options); ClusterState state = mockEmptyMetaData(); @@ -684,7 +692,7 @@ public void testAuditTrailIsRecordedWhenIndexWildcardThrowsError() { verify(state, times(1)).metaData(); } - public void testRunAsRequestWithNoRolesUser() { + public void testRunAsRequestWithNoRolesUser() throws IOException { final TransportRequest request = mock(TransportRequest.class); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); final Authentication authentication = createAuthentication(new User("run as me", null, new User("test user", "admin"))); @@ -696,12 +704,13 @@ public void testRunAsRequestWithNoRolesUser() { verifyNoMoreInteractions(auditTrail); } - public void testRunAsRequestWithoutLookedUpBy() { + public void testRunAsRequestWithoutLookedUpBy() throws IOException { final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); AuthenticateRequest request = new AuthenticateRequest("run as me"); roleMap.put("can run as", ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR); User user = new User("run as me", Strings.EMPTY_ARRAY, new User("test user", new String[]{"can run as"})); Authentication authentication = new Authentication(user, new RealmRef("foo", "bar", "baz"), null); + authentication.writeToContext(threadContext); assertNotEquals(user.authenticatedUser(), user); assertThrowsAuthorizationExceptionRunAs( () -> authorize(authentication, AuthenticateAction.NAME, request), @@ -711,7 +720,7 @@ public void testRunAsRequestWithoutLookedUpBy() { verifyNoMoreInteractions(auditTrail); } - public void testRunAsRequestRunningAsUnAllowedUser() { + public void testRunAsRequestRunningAsUnAllowedUser() throws IOException { TransportRequest request = mock(TransportRequest.class); User user = new User("run as me", new String[]{"doesn't exist"}, new User("test user", "can run as")); assertNotEquals(user.authenticatedUser(), user); @@ -729,7 +738,7 @@ public void testRunAsRequestRunningAsUnAllowedUser() { verifyNoMoreInteractions(auditTrail); } - public void testRunAsRequestWithRunAsUserWithoutPermission() { + public void testRunAsRequestWithRunAsUserWithoutPermission() throws IOException { TransportRequest request = new GetIndexRequest().indices("a"); User authenticatedUser = new User("test user", "can run as"); User user = new User("run as me", new String[]{"b"}, authenticatedUser); @@ -769,7 +778,7 @@ public void testRunAsRequestWithRunAsUserWithoutPermission() { verifyNoMoreInteractions(auditTrail); } - public void testRunAsRequestWithValidPermissions() { + public void testRunAsRequestWithValidPermissions() throws IOException { TransportRequest request = new GetIndexRequest().indices("b"); User authenticatedUser = new User("test user", new String[]{"can run as"}); User user = new User("run as me", new String[]{"b"}, authenticatedUser); @@ -797,7 +806,7 @@ public void testRunAsRequestWithValidPermissions() { verifyNoMoreInteractions(auditTrail); } - public void testNonXPackUserCannotExecuteOperationAgainstSecurityIndex() { + public void testNonXPackUserCannotExecuteOperationAgainstSecurityIndex() throws IOException { RoleDescriptor role = new RoleDescriptor("all access", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("*").privileges("all").build()}, null); final Authentication authentication = createAuthentication(new User("all_access_user", "all_access")); @@ -856,7 +865,7 @@ public void testNonXPackUserCannotExecuteOperationAgainstSecurityIndex() { assertEquals(IndicesAndAliasesResolver.NO_INDICES_OR_ALIASES_LIST, Arrays.asList(searchRequest.indices())); } - public void testGrantedNonXPackUserCanExecuteMonitoringOperationsAgainstSecurityIndex() { + public void testGrantedNonXPackUserCanExecuteMonitoringOperationsAgainstSecurityIndex() throws IOException { RoleDescriptor role = new RoleDescriptor("all access", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("*").privileges("all").build()}, null); final Authentication authentication = createAuthentication(new User("all_access_user", "all_access")); @@ -888,7 +897,7 @@ public void testGrantedNonXPackUserCanExecuteMonitoringOperationsAgainstSecurity } } - public void testSuperusersCanExecuteOperationAgainstSecurityIndex() { + public void testSuperusersCanExecuteOperationAgainstSecurityIndex() throws IOException { final User superuser = new User("custom_admin", ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName()); roleMap.put(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName(), ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR); ClusterState state = mock(ClusterState.class); @@ -926,13 +935,15 @@ public void testSuperusersCanExecuteOperationAgainstSecurityIndex() { for (final Tuple requestTuple : requests) { final String action = requestTuple.v1(); final TransportRequest request = requestTuple.v2(); - final Authentication authentication = createAuthentication(superuser); - authorize(authentication, action, request); - verify(auditTrail).accessGranted(requestId, authentication, action, request, superuser.roles()); + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + final Authentication authentication = createAuthentication(superuser); + authorize(authentication, action, request); + verify(auditTrail).accessGranted(requestId, authentication, action, request, superuser.roles()); + } } } - public void testSuperusersCanExecuteOperationAgainstSecurityIndexWithWildcard() { + public void testSuperusersCanExecuteOperationAgainstSecurityIndexWithWildcard() throws IOException { final User superuser = new User("custom_admin", ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName()); final Authentication authentication = createAuthentication(superuser); roleMap.put(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName(), ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR); @@ -947,54 +958,64 @@ public void testSuperusersCanExecuteOperationAgainstSecurityIndexWithWildcard() String action = SearchAction.NAME; SearchRequest request = new SearchRequest("_all"); - authorize(createAuthentication(superuser), action, request); + authorize(authentication, action, request); verify(auditTrail).accessGranted(requestId, authentication, action, request, superuser.roles()); assertThat(request.indices(), arrayContaining(".security")); } - public void testAnonymousRolesAreAppliedToOtherUsers() { + public void testAnonymousRolesAreAppliedToOtherUsers() throws IOException { TransportRequest request = new ClusterHealthRequest(); Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "anonymous_user_role").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); roleMap.put("anonymous_user_role", new RoleDescriptor("anonymous_user_role", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null)); mockEmptyMetaData(); AuditUtil.getOrGenerateRequestId(threadContext); // sanity check the anonymous user - authorize(createAuthentication(anonymousUser), ClusterHealthAction.NAME, request); - authorize(createAuthentication(anonymousUser), IndicesExistsAction.NAME, new IndicesExistsRequest("a")); + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + authorize(createAuthentication(anonymousUser), ClusterHealthAction.NAME, request); + } + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + authorize(createAuthentication(anonymousUser), IndicesExistsAction.NAME, new IndicesExistsRequest("a")); + } // test the no role user final User userWithNoRoles = new User("no role user"); - authorize(createAuthentication(userWithNoRoles), ClusterHealthAction.NAME, request); - authorize(createAuthentication(userWithNoRoles), IndicesExistsAction.NAME, new IndicesExistsRequest("a")); + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + authorize(createAuthentication(userWithNoRoles), ClusterHealthAction.NAME, request); + } + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + authorize(createAuthentication(userWithNoRoles), IndicesExistsAction.NAME, new IndicesExistsRequest("a")); + } } - public void testDefaultRoleUserWithoutRoles() { + public void testDefaultRoleUserWithoutRoles() throws IOException { PlainActionFuture rolesFuture = new PlainActionFuture<>(); - authorizationService.roles(new User("no role user"), rolesFuture); + final User user = new User("no role user"); + authorizationService.roles(user, createAuthentication(user), rolesFuture); final Role roles = rolesFuture.actionGet(); assertEquals(Role.EMPTY, roles); } - public void testAnonymousUserEnabledRoleAdded() { + public void testAnonymousUserEnabledRoleAdded() throws IOException { Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "anonymous_user_role").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); roleMap.put("anonymous_user_role", new RoleDescriptor("anonymous_user_role", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null)); mockEmptyMetaData(); + final User user = new User("no role user"); PlainActionFuture rolesFuture = new PlainActionFuture<>(); - authorizationService.roles(new User("no role user"), rolesFuture); + authorizationService.roles(user, createAuthentication(user), rolesFuture); final Role roles = rolesFuture.actionGet(); assertThat(Arrays.asList(roles.names()), hasItem("anonymous_user_role")); } - public void testCompositeActionsAreImmediatelyRejected() { + public void testCompositeActionsAreImmediatelyRejected() throws IOException { //if the user has no permission for composite actions against any index, the request fails straight-away in the main action final Tuple compositeRequest = randomCompositeRequest(); final String action = compositeRequest.v1(); @@ -1010,7 +1031,7 @@ public void testCompositeActionsAreImmediatelyRejected() { verifyNoMoreInteractions(auditTrail); } - public void testCompositeActionsIndicesAreNotChecked() { + public void testCompositeActionsIndicesAreNotChecked() throws IOException { //if the user has permission for some index, the request goes through without looking at the indices, they will be checked later final Tuple compositeRequest = randomCompositeRequest(); final String action = compositeRequest.v1(); @@ -1027,7 +1048,7 @@ public void testCompositeActionsIndicesAreNotChecked() { verifyNoMoreInteractions(auditTrail); } - public void testCompositeActionsMustImplementCompositeIndicesRequest() { + public void testCompositeActionsMustImplementCompositeIndicesRequest() throws IOException { String action = randomCompositeRequest().v1(); TransportRequest request = mock(TransportRequest.class); final String requestId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -1040,7 +1061,7 @@ public void testCompositeActionsMustImplementCompositeIndicesRequest() { assertThat(illegalStateException.getMessage(), containsString("Composite actions must implement CompositeIndicesRequest")); } - public void testCompositeActionsIndicesAreCheckedAtTheShardLevel() { + public void testCompositeActionsIndicesAreCheckedAtTheShardLevel() throws IOException { final MockIndicesRequest mockRequest = new MockIndicesRequest(IndicesOptions.strictExpandOpen(), "index"); final TransportRequest request; final String action; @@ -1079,12 +1100,14 @@ public void testCompositeActionsIndicesAreCheckedAtTheShardLevel() { new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null)); AuditUtil.getOrGenerateRequestId(threadContext); mockEmptyMetaData(); - authorize(createAuthentication(userAllowed), action, request); + try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { + authorize(createAuthentication(userAllowed), action, request); + } assertThrowsAuthorizationException( () -> authorize(createAuthentication(userDenied), action, request), action, "userDenied"); } - public void testAuthorizationOfIndividualBulkItems() { + public void testAuthorizationOfIndividualBulkItems() throws IOException { final String action = BulkAction.NAME + "[s]"; final BulkItemRequest[] items = { new BulkItemRequest(1, new DeleteRequest("concrete-index", "doc", "c1")), @@ -1118,7 +1141,7 @@ public void testAuthorizationOfIndividualBulkItems() { verifyNoMoreInteractions(auditTrail); } - public void testAuthorizationOfIndividualBulkItemsWithDateMath() { + public void testAuthorizationOfIndividualBulkItemsWithDateMath() throws IOException { final String action = BulkAction.NAME + "[s]"; final BulkItemRequest[] items = { new BulkItemRequest(1, new IndexRequest("", "doc", "dy1")), @@ -1324,7 +1347,7 @@ private static class MockCompositeIndicesRequest extends TransportRequest implem public void testDoesNotUseRolesStoreForXPackUser() { PlainActionFuture rolesFuture = new PlainActionFuture<>(); - authorizationService.roles(XPackUser.INSTANCE, rolesFuture); + authorizationService.roles(XPackUser.INSTANCE, null, rolesFuture); final Role roles = rolesFuture.actionGet(); assertThat(roles, equalTo(XPackUser.ROLE)); verifyZeroInteractions(rolesStore); @@ -1332,13 +1355,23 @@ public void testDoesNotUseRolesStoreForXPackUser() { public void testGetRolesForSystemUserThrowsException() { IllegalArgumentException iae = expectThrows(IllegalArgumentException.class, () -> authorizationService.roles(SystemUser.INSTANCE, - null)); + null, null)); assertEquals("the user [_system] is the system user and we should never try to get its roles", iae.getMessage()); } - private static Authentication createAuthentication(User user) { + private Authentication createAuthentication(User user) throws IOException { RealmRef lookedUpBy = user.authenticatedUser() == user ? null : new RealmRef("looked", "up", "by"); - return new Authentication(user, new RealmRef("test", "test", "foo"), lookedUpBy); + Authentication authentication = new Authentication(user, new RealmRef("test", "test", "foo"), lookedUpBy); + authentication.writeToContext(threadContext); + return authentication; + } + + private Authentication createAuthentication(User user, AuthenticationType type) throws IOException { + RealmRef lookedUpBy = user.authenticatedUser() == user ? null : new RealmRef("looked", "up", "by"); + Authentication authentication = + new Authentication(user, new RealmRef("test", "test", "foo"), lookedUpBy, Version.CURRENT, type, Collections.emptyMap()); + authentication.writeToContext(threadContext); + return authentication; } private ClusterState mockEmptyMetaData() { @@ -1373,7 +1406,7 @@ public void testProxyRequestFailsOnNonProxyRequest() { endsWith("] but action: [internal:transport/proxy/indices:some/action] is a proxy action")); } - public void testProxyRequestAuthenticationDenied() { + public void testProxyRequestAuthenticationDenied() throws IOException { final TransportRequest proxiedRequest = new SearchRequest(); final DiscoveryNode node = new DiscoveryNode("foo", buildNewFakeTransportAddress(), Version.CURRENT); final TransportRequest transportRequest = TransportActionProxy.wrapRequest(node, proxiedRequest); @@ -1389,7 +1422,7 @@ public void testProxyRequestAuthenticationDenied() { verifyNoMoreInteractions(auditTrail); } - public void testProxyRequestAuthenticationGrantedWithAllPrivileges() { + public void testProxyRequestAuthenticationGrantedWithAllPrivileges() throws IOException { RoleDescriptor role = new RoleDescriptor("a_role", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); final Authentication authentication = createAuthentication(new User("test user", "a_all")); @@ -1406,7 +1439,7 @@ public void testProxyRequestAuthenticationGrantedWithAllPrivileges() { verify(auditTrail).accessGranted(requestId, authentication, action, clearScrollRequest, new String[]{role.getName()}); } - public void testProxyRequestAuthenticationGranted() { + public void testProxyRequestAuthenticationGranted() throws IOException { RoleDescriptor role = new RoleDescriptor("a_role", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("read_cross_cluster").build()}, null); final Authentication authentication = createAuthentication(new User("test user", "a_all")); @@ -1422,7 +1455,7 @@ public void testProxyRequestAuthenticationGranted() { verify(auditTrail).accessGranted(requestId, authentication, action, clearScrollRequest, new String[]{role.getName()}); } - public void testProxyRequestAuthenticationDeniedWithReadPrivileges() { + public void testProxyRequestAuthenticationDeniedWithReadPrivileges() throws IOException { final Authentication authentication = createAuthentication(new User("test user", "a_all")); final RoleDescriptor role = new RoleDescriptor("a_role", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("read").build()}, null); @@ -1437,4 +1470,20 @@ public void testProxyRequestAuthenticationDeniedWithReadPrivileges() { () -> authorize(authentication, action, transportRequest), action, "test user"); verify(auditTrail).accessDenied(requestId, authentication, action, clearScrollRequest, new String[]{role.getName()}); } + + public void testApiKeyAuthUsesApiKeyService() throws IOException { + AuditUtil.getOrGenerateRequestId(threadContext); + final Authentication authentication = createAuthentication(new User("test api key user", "api_key"), AuthenticationType.API_KEY); + doAnswer(invocationOnMock -> { + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[4]; + listener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); + return Void.TYPE; + }).when(apiKeyService).getRoleForApiKey(eq(authentication), eq(threadContext), eq(rolesStore), any(FieldPermissionsCache.class), + any(ActionListener.class)); + + authorize(authentication, "cluster:admin/foo", new ClearScrollRequest()); + verify(apiKeyService).getRoleForApiKey(eq(authentication), eq(threadContext), eq(rolesStore), any(FieldPermissionsCache.class), + any(ActionListener.class)); + verifyZeroInteractions(rolesStore); + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 83edb189e2935..89ee0be915984 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -52,6 +52,8 @@ import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.TransportRequest; import org.elasticsearch.xpack.core.graph.action.GraphExploreAction; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.DefaultAuthenticationFailureHandler; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -64,6 +66,7 @@ import org.elasticsearch.xpack.core.security.user.XPackSecurityUser; import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.audit.AuditTrailService; +import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.authz.IndicesAndAliasesResolver.ResolvedIndices; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -191,7 +194,7 @@ public void setup() { when(clusterService.getClusterSettings()).thenReturn(new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); authzService = new AuthorizationService(settings, rolesStore, clusterService, mock(AuditTrailService.class), new DefaultAuthenticationFailureHandler(Collections.emptyMap()), mock(ThreadPool.class), - new AnonymousUser(settings)); + new AnonymousUser(settings), mock(ApiKeyService.class)); defaultIndicesResolver = new IndicesAndAliasesResolver(settings, clusterService); } @@ -1365,7 +1368,9 @@ public void testDynamicPutMappingRequestFromAlias() { private AuthorizedIndices buildAuthorizedIndices(User user, String action) { PlainActionFuture rolesListener = new PlainActionFuture<>(); - authzService.roles(user, rolesListener); + final Authentication authentication = + new Authentication(user, new RealmRef("test", "indices-aliases-resolver-tests", "node"), null); + authzService.roles(user, authentication, rolesListener); return new AuthorizedIndices(user, rolesListener.actionGet(), action, metaData); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/ServerTransportFilterTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/ServerTransportFilterTests.java index 17df337d2916f..24126c5463da7 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/ServerTransportFilterTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/ServerTransportFilterTests.java @@ -151,10 +151,10 @@ public void testInboundAuthorizationException() throws Exception { final Role empty = Role.EMPTY; doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; callback.onResponse(empty); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); when(authentication.getVersion()).thenReturn(Version.CURRENT); when(authentication.getUser()).thenReturn(XPackUser.INSTANCE); PlainActionFuture future = new PlainActionFuture<>(); @@ -188,10 +188,10 @@ public void testNodeProfileAllowsNodeActions() throws Exception { Authentication authentication = new Authentication(new User("test", "superuser"), new RealmRef("test", "test", "node1"), null); doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[2]; callback.onResponse(authentication.getUser().equals(i.getArguments()[0]) ? ReservedRolesStore.SUPERUSER_ROLE : null); return Void.TYPE; - }).when(authzService).roles(any(User.class), any(ActionListener.class)); + }).when(authzService).roles(any(User.class), any(Authentication.class), any(ActionListener.class)); doAnswer((i) -> { ActionListener callback = (ActionListener) i.getArguments()[3]; @@ -207,12 +207,12 @@ public void testNodeProfileAllowsNodeActions() throws Exception { filter.inbound(internalAction, request, channel, new PlainActionFuture<>()); verify(authcService).authenticate(eq(internalAction), eq(request), eq((User)null), any(ActionListener.class)); - verify(authzService).roles(eq(authentication.getUser()), any(ActionListener.class)); + verify(authzService).roles(eq(authentication.getUser()), any(Authentication.class), any(ActionListener.class)); verify(authzService).authorize(authentication, internalAction, request, ReservedRolesStore.SUPERUSER_ROLE, null); filter.inbound(nodeOrShardAction, request, channel, new PlainActionFuture<>()); verify(authcService).authenticate(eq(nodeOrShardAction), eq(request), eq((User)null), any(ActionListener.class)); - verify(authzService, times(2)).roles(eq(authentication.getUser()), any(ActionListener.class)); + verify(authzService, times(2)).roles(eq(authentication.getUser()), any(Authentication.class), any(ActionListener.class)); verify(authzService).authorize(authentication, nodeOrShardAction, request, ReservedRolesStore.SUPERUSER_ROLE, null); verifyNoMoreInteractions(authcService, authzService); } From 554bf2348099f53ee3f7fb5fe80b059ecd583243 Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Mon, 10 Dec 2018 08:18:26 -0700 Subject: [PATCH 07/26] Enable caching of roles from the api keys (#36387) This commit enables api keys to share the same cache for roles that is already in use for roles from other sources. In order to avoid the possibility of a key collision with roles that do not belong to api keys, the key for the roles cache now includes a source field that prevents these collisions. --- .../xpack/security/Security.java | 5 +- .../xpack/security/authc/ApiKeyService.java | 24 +--- .../security/authz/AuthorizationService.java | 9 +- .../authz/store/CompositeRolesStore.java | 122 ++++++++++++------ .../security/authc/ApiKeyServiceTests.java | 38 +----- .../authz/AuthorizationServiceTests.java | 25 ++-- .../authz/IndicesAndAliasesResolverTests.java | 6 +- .../authz/store/CompositeRolesStoreTests.java | 74 +++++------ 8 files changed, 145 insertions(+), 158 deletions(-) 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 3f567abb5701e..4b4c33c6bea5f 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 @@ -465,6 +465,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final NativePrivilegeStore privilegeStore = new NativePrivilegeStore(settings, client, securityIndex.get()); components.add(privilegeStore); + final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(settings); final FileRolesStore fileRolesStore = new FileRolesStore(settings, env, resourceWatcherService, getLicenseState()); final NativeRolesStore nativeRolesStore = new NativeRolesStore(settings, client, getLicenseState(), securityIndex.get()); final ReservedRolesStore reservedRolesStore = new ReservedRolesStore(); @@ -473,13 +474,13 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste rolesProviders.addAll(extension.getRolesProviders(settings, resourceWatcherService)); } final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, - reservedRolesStore, privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState()); + reservedRolesStore, privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache); securityIndex.get().addIndexStateListener(allRolesStore::onSecurityIndexStateChange); // to keep things simple, just invalidate all cached entries on license change. this happens so rarely that the impact should be // minimal getLicenseState().addListener(allRolesStore::invalidateAll); final AuthorizationService authzService = new AuthorizationService(settings, allRolesStore, clusterService, - auditTrailService, failureHandler, threadPool, anonymousUser, apiKeyService); + auditTrailService, failureHandler, threadPool, anonymousUser, apiKeyService, fieldPermissionsCache); components.add(nativeRolesStore); // used by roles actions components.add(reservedRolesStore); // used by roles actions components.add(allRolesStore); // for SecurityFeatureSet and clear roles cache diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 808817c4bc3eb..1c860766b4751 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -38,7 +38,6 @@ import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; -import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache; import org.elasticsearch.xpack.core.security.authz.permission.Role; import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; @@ -96,8 +95,7 @@ public class ApiKeyService { private final Hasher hasher; private final boolean enabled; - public ApiKeyService(Settings settings, Clock clock, Client client, - SecurityIndexManager securityIndex, ClusterService clusterService) { + public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex, ClusterService clusterService) { this.clock = clock; this.client = client; this.securityIndex = securityIndex; @@ -221,25 +219,13 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener listener) { + public void getRoleForApiKey(Authentication authentication, CompositeRolesStore rolesStore, ActionListener listener) { if (authentication.getAuthenticationType() != Authentication.AuthenticationType.API_KEY) { throw new IllegalStateException("authentication type must be api key but is " + authentication.getAuthenticationType()); } final Map metadata = authentication.getMetadata(); final String apiKeyId = (String) metadata.get(API_KEY_ID_KEY); - final String contextKeyId = threadContext.getTransient(API_KEY_ID_KEY); - if (apiKeyId.equals(contextKeyId)) { - final Role preBuiltRole = threadContext.getTransient(API_KEY_ROLE_KEY); - if (preBuiltRole != null) { - listener.onResponse(preBuiltRole); - return; - } - } else if (contextKeyId != null) { - throw new IllegalStateException("authentication api key id [" + apiKeyId + "] does not match context value [" + - contextKeyId + "]"); - } final Map roleDescriptors = (Map) metadata.get(API_KEY_ROLE_DESCRIPTORS_KEY); final List roleDescriptorList = roleDescriptors.entrySet().stream() @@ -258,11 +244,7 @@ public void getRoleForApiKey(Authentication authentication, ThreadContext thread } }).collect(Collectors.toList()); - rolesStore.buildRoleFromDescriptors(roleDescriptorList, fieldPermissionsCache, ActionListener.wrap(role -> { - threadContext.putTransient(API_KEY_ID_KEY, apiKeyId); - threadContext.putTransient(API_KEY_ROLE_KEY, role); - listener.onResponse(role); - }, listener::onFailure)); + rolesStore.buildAndCacheRoleFromDescriptors(roleDescriptorList, apiKeyId, listener); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index ae5dd7e36cf59..99a8e9c7c48b3 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -112,7 +112,8 @@ public class AuthorizationService { public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, ClusterService clusterService, AuditTrailService auditTrail, AuthenticationFailureHandler authcFailureHandler, - ThreadPool threadPool, AnonymousUser anonymousUser, ApiKeyService apiKeyService) { + ThreadPool threadPool, AnonymousUser anonymousUser, ApiKeyService apiKeyService, + FieldPermissionsCache fieldPermissionsCache) { this.rolesStore = rolesStore; this.clusterService = clusterService; this.auditTrail = auditTrail; @@ -122,7 +123,7 @@ public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, C this.anonymousUser = anonymousUser; this.isAnonymousEnabled = AnonymousUser.isAnonymousEnabled(settings); this.anonymousAuthzExceptionEnabled = ANONYMOUS_AUTHORIZATION_EXCEPTION_SETTING.get(settings); - this.fieldPermissionsCache = new FieldPermissionsCache(settings); + this.fieldPermissionsCache = fieldPermissionsCache; this.apiKeyService = apiKeyService; } @@ -480,7 +481,7 @@ public void roles(User user, Authentication authentication, ActionListener final Authentication.AuthenticationType authType = authentication.getAuthenticationType(); if (authType == Authentication.AuthenticationType.API_KEY) { - apiKeyService.getRoleForApiKey(authentication, threadContext, rolesStore, fieldPermissionsCache, roleActionListener); + apiKeyService.getRoleForApiKey(authentication, rolesStore, roleActionListener); } else { Set roleNames = new HashSet<>(); Collections.addAll(roleNames, user.roles()); @@ -496,7 +497,7 @@ public void roles(User user, Authentication authentication, ActionListener } else if (roleNames.contains(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName())) { roleActionListener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); } else { - rolesStore.roles(roleNames, fieldPermissionsCache, roleActionListener); + rolesStore.roles(roleNames, roleActionListener); } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index b4c2a34fc747c..378760604811d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -68,7 +68,7 @@ */ public class CompositeRolesStore { - + private static final String ROLES_STORE_SOURCE = "roles_stores"; private static final Setting CACHE_SIZE_SETTING = Setting.intSetting("xpack.security.authz.store.roles.cache.max_size", 10000, Property.NodeScope); private static final Setting NEGATIVE_LOOKUP_CACHE_SIZE_SETTING = @@ -92,7 +92,8 @@ public class CompositeRolesStore { private final NativeRolesStore nativeRolesStore; private final NativePrivilegeStore privilegeStore; private final XPackLicenseState licenseState; - private final Cache, Role> roleCache; + private final FieldPermissionsCache fieldPermissionsCache; + private final Cache roleCache; private final Cache negativeLookupCache; private final ThreadContext threadContext; private final AtomicLong numInvalidation = new AtomicLong(); @@ -102,13 +103,14 @@ public class CompositeRolesStore { public CompositeRolesStore(Settings settings, FileRolesStore fileRolesStore, NativeRolesStore nativeRolesStore, ReservedRolesStore reservedRolesStore, NativePrivilegeStore privilegeStore, List, ActionListener>> rolesProviders, - ThreadContext threadContext, XPackLicenseState licenseState) { + ThreadContext threadContext, XPackLicenseState licenseState, FieldPermissionsCache fieldPermissionsCache) { this.fileRolesStore = fileRolesStore; fileRolesStore.addListener(this::invalidate); this.nativeRolesStore = nativeRolesStore; this.privilegeStore = privilegeStore; this.licenseState = licenseState; - CacheBuilder, Role> builder = CacheBuilder.builder(); + this.fieldPermissionsCache = fieldPermissionsCache; + CacheBuilder builder = CacheBuilder.builder(); final int cacheSize = CACHE_SIZE_SETTING.get(settings); if (cacheSize >= 0) { builder.setMaximumWeight(cacheSize); @@ -133,8 +135,9 @@ public CompositeRolesStore(Settings settings, FileRolesStore fileRolesStore, Nat } } - public void roles(Set roleNames, FieldPermissionsCache fieldPermissionsCache, ActionListener roleActionListener) { - Role existing = roleCache.get(roleNames); + public void roles(Set roleNames, ActionListener roleActionListener) { + final RoleKey roleKey = new RoleKey(roleNames, ROLES_STORE_SOURCE); + Role existing = roleCache.get(roleKey); if (existing != null) { roleActionListener.onResponse(existing); } else { @@ -154,33 +157,54 @@ public void roles(Set roleNames, FieldPermissionsCache fieldPermissionsC .filter((rd) -> rd.isUsingDocumentOrFieldLevelSecurity() == false) .collect(Collectors.toSet()); } - logger.trace("Building role from descriptors [{}] for names [{}]", effectiveDescriptors, roleNames); - buildRoleFromDescriptors(effectiveDescriptors, fieldPermissionsCache, privilegeStore, ActionListener.wrap(role -> { - if (role != null && rolesRetrievalResult.isSuccess()) { - try (ReleasableLock ignored = readLock.acquire()) { - /* this is kinda spooky. We use a read/write lock to ensure we don't modify the cache if we hold - * the write lock (fetching stats for instance - which is kinda overkill?) but since we fetching - * stuff in an async fashion we need to make sure that if the cache got invalidated since we - * started the request we don't put a potential stale result in the cache, hence the - * numInvalidation.get() comparison to the number of invalidation when we started. we just try to - * be on the safe side and don't cache potentially stale results - */ - if (invalidationCounter == numInvalidation.get()) { - roleCache.computeIfAbsent(roleNames, (s) -> role); - } - } - - for (String missingRole : rolesRetrievalResult.getMissingRoles()) { - negativeLookupCache.computeIfAbsent(missingRole, s -> Boolean.TRUE); - } - } - roleActionListener.onResponse(role); - }, roleActionListener::onFailure)); + buildThenMaybeCacheRole(roleKey, effectiveDescriptors, rolesRetrievalResult.getMissingRoles(), + rolesRetrievalResult.isSuccess(), invalidationCounter, roleActionListener); }, roleActionListener::onFailure)); } } + public void buildAndCacheRoleFromDescriptors(Collection roleDescriptors, String source, + ActionListener listener) { + if (ROLES_STORE_SOURCE.equals(source)) { + throw new IllegalArgumentException("source [" + ROLES_STORE_SOURCE + "] is reserved for internal use"); + } + RoleKey roleKey = new RoleKey(roleDescriptors.stream().map(RoleDescriptor::getName).collect(Collectors.toSet()), source); + Role existing = roleCache.get(roleKey); + if (existing != null) { + listener.onResponse(existing); + } else { + final long invalidationCounter = numInvalidation.get(); + buildThenMaybeCacheRole(roleKey, roleDescriptors, Collections.emptySet(), true, invalidationCounter, listener); + } + } + + private void buildThenMaybeCacheRole(RoleKey roleKey, Collection roleDescriptors, Set missing, + boolean tryCache, long invalidationCounter, ActionListener listener) { + logger.trace("Building role from descriptors [{}] for names [{}] from source [{}]", roleDescriptors, roleKey.names, roleKey.source); + buildRoleFromDescriptors(roleDescriptors, fieldPermissionsCache, privilegeStore, ActionListener.wrap(role -> { + if (role != null && tryCache) { + try (ReleasableLock ignored = readLock.acquire()) { + /* this is kinda spooky. We use a read/write lock to ensure we don't modify the cache if we hold + * the write lock (fetching stats for instance - which is kinda overkill?) but since we fetching + * stuff in an async fashion we need to make sure that if the cache got invalidated since we + * started the request we don't put a potential stale result in the cache, hence the + * numInvalidation.get() comparison to the number of invalidation when we started. we just try to + * be on the safe side and don't cache potentially stale results + */ + if (invalidationCounter == numInvalidation.get()) { + roleCache.computeIfAbsent(roleKey, (s) -> role); + } + } + + for (String missingRole : missing) { + negativeLookupCache.computeIfAbsent(missingRole, s -> Boolean.TRUE); + } + } + listener.onResponse(role); + }, listener::onFailure)); + } + public void getRoleDescriptors(Set roleNames, ActionListener> listener) { roleDescriptors(roleNames, ActionListener.wrap(rolesRetrievalResult -> { if (rolesRetrievalResult.isSuccess()) { @@ -241,11 +265,6 @@ private String names(Collection descriptors) { return descriptors.stream().map(RoleDescriptor::getName).collect(Collectors.joining(",")); } - public void buildRoleFromDescriptors(Collection roleDescriptors, FieldPermissionsCache fieldPermissionsCache, - ActionListener listener) { - buildRoleFromDescriptors(roleDescriptors, fieldPermissionsCache, privilegeStore, listener); - } - public static void buildRoleFromDescriptors(Collection roleDescriptors, FieldPermissionsCache fieldPermissionsCache, NativePrivilegeStore privilegeStore, ActionListener listener) { if (roleDescriptors.isEmpty()) { @@ -346,10 +365,10 @@ public void invalidate(String role) { // the cache cannot be modified while doing this operation per the terms of the cache iterator try (ReleasableLock ignored = writeLock.acquire()) { - Iterator> keyIter = roleCache.keys().iterator(); + Iterator keyIter = roleCache.keys().iterator(); while (keyIter.hasNext()) { - Set key = keyIter.next(); - if (key.contains(role)) { + RoleKey key = keyIter.next(); + if (key.names.contains(role)) { keyIter.remove(); } } @@ -362,10 +381,10 @@ public void invalidate(Set roles) { // the cache cannot be modified while doing this operation per the terms of the cache iterator try (ReleasableLock ignored = writeLock.acquire()) { - Iterator> keyIter = roleCache.keys().iterator(); + Iterator keyIter = roleCache.keys().iterator(); while (keyIter.hasNext()) { - Set key = keyIter.next(); - if (Sets.haveEmptyIntersection(key, roles) == false) { + RoleKey key = keyIter.next(); + if (Sets.haveEmptyIntersection(key.names, roles) == false) { keyIter.remove(); } } @@ -461,6 +480,31 @@ private Set getMissingRoles() { } } + private static final class RoleKey { + + private final Set names; + private final String source; + + private RoleKey(Set names, String source) { + this.names = Objects.requireNonNull(names); + this.source = Objects.requireNonNull(source); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoleKey roleKey = (RoleKey) o; + return names.equals(roleKey.names) && + source.equals(roleKey.source); + } + + @Override + public int hashCode() { + return Objects.hash(names, source); + } + } + public static List> getSettings() { return Arrays.asList(CACHE_SIZE_SETTING, NEGATIVE_LOOKUP_CACHE_SIZE_SETTING); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index 4f75f1158a968..cee5711780f11 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -28,7 +28,6 @@ import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; -import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache; import org.elasticsearch.xpack.core.security.authz.permission.Role; import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore; import org.elasticsearch.xpack.core.security.user.User; @@ -45,7 +44,6 @@ import java.util.HashMap; import java.util.Map; -import static org.hamcrest.Matchers.containsString; import static org.mockito.Matchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -151,38 +149,6 @@ public void testValidateApiKey() throws Exception { assertFalse(result.isAuthenticated()); } - public void testGetRolesForApiKeyFromContext() throws Exception { - final String keyId = randomAlphaOfLength(12); - final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, - Version.CURRENT, AuthenticationType.API_KEY, Collections.singletonMap(ApiKeyService.API_KEY_ID_KEY, keyId)); - final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); - threadContext.putTransient(ApiKeyService.API_KEY_ID_KEY, keyId); - threadContext.putTransient(ApiKeyService.API_KEY_ROLE_KEY, ReservedRolesStore.SUPERUSER_ROLE); - ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, - ClusterServiceUtils.createClusterService(threadPool)); - PlainActionFuture roleFuture = new PlainActionFuture<>(); - service.getRoleForApiKey(authentication, threadContext, mock(CompositeRolesStore.class), new FieldPermissionsCache(Settings.EMPTY), - roleFuture); - Role role = roleFuture.get(); - assertEquals(ReservedRolesStore.SUPERUSER_ROLE, role); - } - - public void testGetRolesForApiKeyIncorrectKeyId() { - final String keyId = randomAlphaOfLength(12); - final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, - Version.CURRENT, AuthenticationType.API_KEY, Collections.singletonMap(ApiKeyService.API_KEY_ID_KEY, keyId)); - final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); - threadContext.putTransient(ApiKeyService.API_KEY_ID_KEY, randomAlphaOfLength(11)); - threadContext.putTransient(ApiKeyService.API_KEY_ROLE_KEY, ReservedRolesStore.SUPERUSER_ROLE); - ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, - ClusterServiceUtils.createClusterService(threadPool)); - PlainActionFuture roleFuture = new PlainActionFuture<>(); - IllegalStateException ise = expectThrows(IllegalStateException.class, () -> service.getRoleForApiKey(authentication, threadContext, - mock(CompositeRolesStore.class),new FieldPermissionsCache(Settings.EMPTY), roleFuture)); - assertThat(ise.getMessage(), containsString(keyId)); - assertThat(ise.getMessage(), containsString(threadContext.getTransient(ApiKeyService.API_KEY_ID_KEY))); - } - public void testGetRolesForApiKeyNotInContext() throws Exception { Map superUserRdMap; try (XContentBuilder builder = JsonXContent.contentBuilder()) { @@ -214,10 +180,10 @@ public void testGetRolesForApiKeyNotInContext() throws Exception { listener.onFailure(new IllegalStateException("unexpected role name " + descriptors.iterator().next().getName())); } return Void.TYPE; - }).when(rolesStore).buildRoleFromDescriptors(any(Collection.class), any(FieldPermissionsCache.class), any(ActionListener.class)); + }).when(rolesStore).buildAndCacheRoleFromDescriptors(any(Collection.class), any(String.class), any(ActionListener.class)); PlainActionFuture roleFuture = new PlainActionFuture<>(); - service.getRoleForApiKey(authentication, threadContext, rolesStore, new FieldPermissionsCache(Settings.EMPTY), roleFuture); + service.getRoleForApiKey(authentication, rolesStore, roleFuture); Role role = roleFuture.get(); assertThat(role.names(), arrayContaining("superuser")); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java index 797a7694fb607..fa1f7274cf8db 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java @@ -211,7 +211,7 @@ public void setup() { ).when(privilegesStore).getPrivileges(any(Collection.class), any(Collection.class), any(ActionListener.class)); doAnswer((i) -> { - ActionListener callback = (ActionListener) i.getArguments()[2]; + ActionListener callback = (ActionListener) i.getArguments()[1]; Set names = (Set) i.getArguments()[0]; assertNotNull(names); Set roleDescriptors = new HashSet<>(); @@ -230,11 +230,11 @@ public void setup() { ); } return Void.TYPE; - }).when(rolesStore).roles(any(Set.class), any(FieldPermissionsCache.class), any(ActionListener.class)); + }).when(rolesStore).roles(any(Set.class), any(ActionListener.class)); apiKeyService = mock(ApiKeyService.class); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), - apiKeyService); + apiKeyService, new FieldPermissionsCache(Settings.EMPTY)); } private void authorize(Authentication authentication, String action, TransportRequest request) { @@ -628,7 +628,8 @@ public void testDenialForAnonymousUser() throws IOException { Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "a_all").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService, + new FieldPermissionsCache(settings)); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[] { IndicesPrivileges.builder().indices("a").privileges("all").build() }, null); @@ -655,7 +656,7 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() throws IO final Authentication authentication = createAuthentication(new AnonymousUser(settings)); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), - apiKeyService); + apiKeyService, new FieldPermissionsCache(settings)); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); @@ -968,7 +969,8 @@ public void testAnonymousRolesAreAppliedToOtherUsers() throws IOException { Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "anonymous_user_role").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService, + new FieldPermissionsCache(settings)); roleMap.put("anonymous_user_role", new RoleDescriptor("anonymous_user_role", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null)); mockEmptyMetaData(); @@ -1004,7 +1006,8 @@ public void testAnonymousUserEnabledRoleAdded() throws IOException { Settings settings = Settings.builder().put(AnonymousUser.ROLES_SETTING.getKey(), "anonymous_user_role").build(); final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrail, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, apiKeyService, + new FieldPermissionsCache(settings)); roleMap.put("anonymous_user_role", new RoleDescriptor("anonymous_user_role", new String[]{"all"}, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null)); mockEmptyMetaData(); @@ -1475,15 +1478,13 @@ public void testApiKeyAuthUsesApiKeyService() throws IOException { AuditUtil.getOrGenerateRequestId(threadContext); final Authentication authentication = createAuthentication(new User("test api key user", "api_key"), AuthenticationType.API_KEY); doAnswer(invocationOnMock -> { - ActionListener listener = (ActionListener) invocationOnMock.getArguments()[4]; + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; listener.onResponse(ReservedRolesStore.SUPERUSER_ROLE); return Void.TYPE; - }).when(apiKeyService).getRoleForApiKey(eq(authentication), eq(threadContext), eq(rolesStore), any(FieldPermissionsCache.class), - any(ActionListener.class)); + }).when(apiKeyService).getRoleForApiKey(eq(authentication), eq(rolesStore), any(ActionListener.class)); authorize(authentication, "cluster:admin/foo", new ClearScrollRequest()); - verify(apiKeyService).getRoleForApiKey(eq(authentication), eq(threadContext), eq(rolesStore), any(FieldPermissionsCache.class), - any(ActionListener.class)); + verify(apiKeyService).getRoleForApiKey(eq(authentication), eq(rolesStore), any(ActionListener.class)); verifyZeroInteractions(rolesStore); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 89ee0be915984..b7a85407a6f36 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -169,7 +169,7 @@ public void setup() { final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[2]; + (ActionListener) i.getArguments()[1]; Set names = (Set) i.getArguments()[0]; assertNotNull(names); Set roleDescriptors = new HashSet<>(); @@ -188,13 +188,13 @@ public void setup() { ); } return Void.TYPE; - }).when(rolesStore).roles(any(Set.class), any(FieldPermissionsCache.class), any(ActionListener.class)); + }).when(rolesStore).roles(any(Set.class), any(ActionListener.class)); ClusterService clusterService = mock(ClusterService.class); when(clusterService.getClusterSettings()).thenReturn(new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); authzService = new AuthorizationService(settings, rolesStore, clusterService, mock(AuditTrailService.class), new DefaultAuthenticationFailureHandler(Collections.emptyMap()), mock(ThreadPool.class), - new AnonymousUser(settings), mock(ApiKeyService.class)); + new AnonymousUser(settings), mock(ApiKeyService.class), new FieldPermissionsCache(settings)); defaultIndicesResolver = new IndicesAndAliasesResolver(settings, clusterService); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java index 6801ffd6bdf84..866b98096f461 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java @@ -79,6 +79,8 @@ public class CompositeRolesStoreTests extends ESTestCase { .put(XPackSettings.SECURITY_ENABLED.getKey(), true) .build(); + private final FieldPermissionsCache cache = new FieldPermissionsCache(Settings.EMPTY); + public void testRolesWhenDlsFlsUnlicensed() throws IOException { XPackLicenseState licenseState = mock(XPackLicenseState.class); when(licenseState.isDocumentAndFieldLevelSecurityAllowed()).thenReturn(false); @@ -126,23 +128,22 @@ public void testRolesWhenDlsFlsUnlicensed() throws IOException { when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); CompositeRolesStore compositeRolesStore = new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), - new ThreadContext(Settings.EMPTY), licenseState); + new ThreadContext(Settings.EMPTY), licenseState, cache); - FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); PlainActionFuture roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("fls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("fls"), roleFuture); assertEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("dls"), roleFuture); assertEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("fls_dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("fls_dls"), roleFuture); assertEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("no_fls_dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("no_fls_dls"), roleFuture); assertNotEquals(Role.EMPTY, roleFuture.actionGet()); } @@ -192,23 +193,22 @@ public void testRolesWhenDlsFlsLicensed() throws IOException { when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); CompositeRolesStore compositeRolesStore = new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), - new ThreadContext(Settings.EMPTY), licenseState); + new ThreadContext(Settings.EMPTY), licenseState, cache); - FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); PlainActionFuture roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("fls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("fls"), roleFuture); assertNotEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("dls"), roleFuture); assertNotEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("fls_dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("fls_dls"), roleFuture); assertNotEquals(Role.EMPTY, roleFuture.actionGet()); roleFuture = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("no_fls_dls"), fieldPermissionsCache, roleFuture); + compositeRolesStore.roles(Collections.singleton("no_fls_dls"), roleFuture); assertNotEquals(Role.EMPTY, roleFuture.actionGet()); } @@ -228,13 +228,12 @@ public void testNegativeLookupsAreCached() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS)); + new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor final String roleName = randomAlphaOfLengthBetween(1, 10); PlainActionFuture future = new PlainActionFuture<>(); - final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(Collections.singleton(roleName), fieldPermissionsCache, future); + compositeRolesStore.roles(Collections.singleton(roleName), future); final Role role = future.actionGet(); assertEquals(Role.EMPTY, role); verify(reservedRolesStore).accept(anySetOf(String.class), any(ActionListener.class)); @@ -250,7 +249,7 @@ public void testNegativeLookupsAreCached() { : Collections.singleton(roleName); for (int i = 0; i < numberOfTimesToCall; i++) { future = new PlainActionFuture<>(); - compositeRolesStore.roles(names, fieldPermissionsCache, future); + compositeRolesStore.roles(names, future); future.actionGet(); } @@ -279,13 +278,12 @@ public void testNegativeLookupsCacheDisabled() { .build(); final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(settings), - new XPackLicenseState(settings)); + new XPackLicenseState(settings), cache); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor final String roleName = randomAlphaOfLengthBetween(1, 10); PlainActionFuture future = new PlainActionFuture<>(); - final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(Collections.singleton(roleName), fieldPermissionsCache, future); + compositeRolesStore.roles(Collections.singleton(roleName), future); final Role role = future.actionGet(); assertEquals(Role.EMPTY, role); verify(reservedRolesStore).accept(anySetOf(String.class), any(ActionListener.class)); @@ -314,13 +312,12 @@ public void testNegativeLookupsAreNotCachedWithFailures() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS)); + new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache); verify(fileRolesStore).addListener(any(Consumer.class)); // adds a listener in ctor final String roleName = randomAlphaOfLengthBetween(1, 10); PlainActionFuture future = new PlainActionFuture<>(); - final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(Collections.singleton(roleName), fieldPermissionsCache, future); + compositeRolesStore.roles(Collections.singleton(roleName), future); final Role role = future.actionGet(); assertEquals(Role.EMPTY, role); verify(reservedRolesStore).accept(anySetOf(String.class), any(ActionListener.class)); @@ -333,7 +330,7 @@ public void testNegativeLookupsAreNotCachedWithFailures() { final Set names = Collections.singleton(roleName); for (int i = 0; i < numberOfTimesToCall; i++) { future = new PlainActionFuture<>(); - compositeRolesStore.roles(names, fieldPermissionsCache, future); + compositeRolesStore.roles(names, future); future.actionGet(); } @@ -392,12 +389,11 @@ public void testCustomRolesProviders() { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider1, inMemoryProvider2), - new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS)); + new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache); final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); PlainActionFuture future = new PlainActionFuture<>(); - final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + compositeRolesStore.roles(roleNames, future); final Role role = future.actionGet(); // make sure custom roles providers populate roles correctly @@ -414,7 +410,7 @@ public void testCustomRolesProviders() { final int numberOfTimesToCall = scaledRandomIntBetween(1, 8); for (int i = 0; i < numberOfTimesToCall; i++) { future = new PlainActionFuture<>(); - compositeRolesStore.roles(Collections.singleton("unknown"), fieldPermissionsCache, future); + compositeRolesStore.roles(Collections.singleton("unknown"), future); future.actionGet(); } @@ -597,12 +593,11 @@ public void testCustomRolesProviderFailures() throws Exception { final CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Arrays.asList(inMemoryProvider1, failingProvider), - new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS)); + new ThreadContext(SECURITY_ENABLED_SETTINGS), new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache); final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); PlainActionFuture future = new PlainActionFuture<>(); - final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + compositeRolesStore.roles(roleNames, future); try { future.get(); fail("provider should have thrown a failure"); @@ -640,12 +635,11 @@ public void testCustomRolesProvidersLicensing() { xPackLicenseState.update(randomFrom(OperationMode.BASIC, OperationMode.GOLD, OperationMode.STANDARD), true, null); CompositeRolesStore compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), - Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState); + Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache); Set roleNames = Sets.newHashSet("roleA"); PlainActionFuture future = new PlainActionFuture<>(); - FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + compositeRolesStore.roles(roleNames, future); Role role = future.actionGet(); // no roles should've been populated, as the license doesn't permit custom role providers @@ -653,13 +647,12 @@ Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(Nativ compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), - Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState); + Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache); // these licenses allow custom role providers xPackLicenseState.update(randomFrom(OperationMode.PLATINUM, OperationMode.TRIAL), true, null); roleNames = Sets.newHashSet("roleA"); future = new PlainActionFuture<>(); - fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + compositeRolesStore.roles(roleNames, future); role = future.actionGet(); // roleA should've been populated by the custom role provider, because the license allows it @@ -668,12 +661,11 @@ Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(Nativ // license expired, don't allow custom role providers compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), - Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState); + Arrays.asList(inMemoryProvider), new ThreadContext(Settings.EMPTY), xPackLicenseState, cache); xPackLicenseState.update(randomFrom(OperationMode.PLATINUM, OperationMode.TRIAL), false, null); roleNames = Sets.newHashSet("roleA"); future = new PlainActionFuture<>(); - fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); - compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + compositeRolesStore.roles(roleNames, future); role = future.actionGet(); assertEquals(0, role.indices().groups().length); } @@ -694,7 +686,7 @@ public void testCacheClearOnIndexHealthChange() { CompositeRolesStore compositeRolesStore = new CompositeRolesStore( Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(Settings.EMPTY), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS)) { + new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache) { @Override public void invalidateAll() { numInvalidation.incrementAndGet(); @@ -746,7 +738,7 @@ public void testCacheClearOnIndexOutOfDateChange() { CompositeRolesStore compositeRolesStore = new CompositeRolesStore(SECURITY_ENABLED_SETTINGS, fileRolesStore, nativeRolesStore, reservedRolesStore, mock(NativePrivilegeStore.class), Collections.emptyList(), new ThreadContext(SECURITY_ENABLED_SETTINGS), - new XPackLicenseState(SECURITY_ENABLED_SETTINGS)) { + new XPackLicenseState(SECURITY_ENABLED_SETTINGS), cache) { @Override public void invalidateAll() { numInvalidation.incrementAndGet(); From 7989b95016157ac77673b7295c3b003888d56cad Mon Sep 17 00:00:00 2001 From: Jay Modi Date: Thu, 13 Dec 2018 08:26:05 -0700 Subject: [PATCH 08/26] Add internal and anonymous authentication types (#36331) This change builds upon the work done in #35970 and adds appropriate types for anonymous and internal authentication to the `AuthenticationType` enum. --- .../xpack/core/security/SecurityContext.java | 11 +++-- .../core/security/authc/Authentication.java | 4 +- .../security/authc/AuthenticationService.java | 8 +++- .../xpack/security/authc/TokenService.java | 8 ++-- .../xpack/security/SecurityContextTests.java | 7 ++++ .../authc/AuthenticationServiceTests.java | 21 ++++++++-- .../security/authc/TokenServiceTests.java | 41 +++++++++++++------ 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java index c737ab75d81aa..0da07a52996ad 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java @@ -13,9 +13,11 @@ import org.elasticsearch.common.util.concurrent.ThreadContext.StoredContext; import org.elasticsearch.node.Node; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.user.User; import java.io.IOException; +import java.util.Collections; import java.util.Objects; import java.util.function.Consumer; @@ -71,7 +73,8 @@ public void setUser(User user, Version version) { } else { lookedUpBy = null; } - setAuthentication(new Authentication(user, authenticatedBy, lookedUpBy, version)); + setAuthentication( + new Authentication(user, authenticatedBy, lookedUpBy, version, AuthenticationType.INTERNAL, Collections.emptyMap())); } /** Writes the authentication to the thread context */ @@ -89,7 +92,7 @@ private void setAuthentication(Authentication authentication) { */ public void executeAsUser(User user, Consumer consumer, Version version) { final StoredContext original = threadContext.newStoredContext(true); - try (ThreadContext.StoredContext ctx = threadContext.stashContext()) { + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { setUser(user, version); consumer.accept(original); } @@ -102,9 +105,9 @@ public void executeAsUser(User user, Consumer consumer, Version v public void executeAfterRewritingAuthentication(Consumer consumer, Version version) { final StoredContext original = threadContext.newStoredContext(true); final Authentication authentication = Objects.requireNonNull(userSettings.getAuthentication()); - try (ThreadContext.StoredContext ctx = threadContext.stashContext()) { + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { setAuthentication(new Authentication(authentication.getUser(), authentication.getAuthenticatedBy(), - authentication.getLookedUpBy(), version)); + authentication.getLookedUpBy(), version, authentication.getAuthenticationType(), authentication.getMetadata())); consumer.accept(original); } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java index 1c6234a6e4143..a93cc44aadb23 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java @@ -274,7 +274,9 @@ public int hashCode() { public enum AuthenticationType { REALM, API_KEY, - TOKEN + TOKEN, + ANONYMOUS, + INTERNAL } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 06b8db33eaa8f..27210c1cefda0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -24,6 +24,7 @@ import org.elasticsearch.transport.TransportMessage; import org.elasticsearch.xpack.core.common.IteratingActionListener; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationFailureHandler; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; @@ -40,6 +41,7 @@ import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.support.RealmUserLookup; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -360,10 +362,12 @@ void handleNullToken() { final Authentication authentication; if (fallbackUser != null) { RealmRef authenticatedBy = new RealmRef("__fallback", "__fallback", nodeName); - authentication = new Authentication(fallbackUser, authenticatedBy, null); + authentication = new Authentication(fallbackUser, authenticatedBy, null, Version.CURRENT, AuthenticationType.INTERNAL, + Collections.emptyMap()); } else if (isAnonymousUserEnabled) { RealmRef authenticatedBy = new RealmRef("__anonymous", "__anonymous", nodeName); - authentication = new Authentication(anonymousUser, authenticatedBy, null); + authentication = new Authentication(anonymousUser, authenticatedBy, null, Version.CURRENT, AuthenticationType.ANONYMOUS, + Collections.emptyMap()); } else { authentication = null; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index eaae9f2179330..9e8f6723377cb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -73,6 +73,7 @@ import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.ScrollHelper; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.KeyAndTimestamp; import org.elasticsearch.xpack.core.security.authc.TokenMetaData; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -230,10 +231,9 @@ public void createUserToken(Authentication authentication, Authentication origin final Instant created = clock.instant(); final Instant expiration = getExpirationTime(created); final Version version = clusterService.state().nodes().getMinNodeVersion(); - final Authentication matchingVersionAuth = version.equals(authentication.getVersion()) ? authentication : - new Authentication(authentication.getUser(), authentication.getAuthenticatedBy(), authentication.getLookedUpBy(), - version); - final UserToken userToken = new UserToken(version, matchingVersionAuth, expiration, metadata); + final Authentication tokenAuth = new Authentication(authentication.getUser(), authentication.getAuthenticatedBy(), + authentication.getLookedUpBy(), version, AuthenticationType.TOKEN, authentication.getMetadata()); + final UserToken userToken = new UserToken(version, tokenAuth, expiration, metadata); final String refreshToken = includeRefreshToken ? UUIDs.randomBase64UUID() : null; try (XContentBuilder builder = XContentFactory.jsonBuilder()) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityContextTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityContextTests.java index e3b1cd31246a2..8fa280b68de37 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityContextTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityContextTests.java @@ -13,6 +13,7 @@ import org.elasticsearch.test.VersionUtils; import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.user.SystemUser; import org.elasticsearch.xpack.core.security.user.User; @@ -56,6 +57,7 @@ public void testSetUser() { assertNull(securityContext.getUser()); securityContext.setUser(user, Version.CURRENT); assertEquals(user, securityContext.getUser()); + assertEquals(AuthenticationType.INTERNAL, securityContext.getAuthentication().getAuthenticationType()); IllegalStateException e = expectThrows(IllegalStateException.class, () -> securityContext.setUser(randomFrom(user, SystemUser.INSTANCE), Version.CURRENT)); @@ -76,11 +78,15 @@ public void testExecuteAsUser() throws IOException { final AtomicReference contextAtomicReference = new AtomicReference<>(); securityContext.executeAsUser(executionUser, (originalCtx) -> { assertEquals(executionUser, securityContext.getUser()); + assertEquals(AuthenticationType.INTERNAL, securityContext.getAuthentication().getAuthenticationType()); contextAtomicReference.set(originalCtx); }, Version.CURRENT); final User userAfterExecution = securityContext.getUser(); assertEquals(original, userAfterExecution); + if (original != null) { + assertEquals(AuthenticationType.REALM, securityContext.getAuthentication().getAuthenticationType()); + } StoredContext originalContext = contextAtomicReference.get(); assertNotNull(originalContext); originalContext.restore(); @@ -100,6 +106,7 @@ public void testExecuteAfterRewritingAuthentication() throws IOException { assertEquals(original.getAuthenticatedBy(), authentication.getAuthenticatedBy()); assertEquals(original.getLookedUpBy(), authentication.getLookedUpBy()); assertEquals(VersionUtils.getPreviousVersion(), authentication.getVersion()); + assertEquals(original.getAuthenticationType(), securityContext.getAuthentication().getAuthenticationType()); contextAtomicReference.set(originalCtx); }, VersionUtils.getPreviousVersion()); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index b53736acc0c65..8c9b884ae8c76 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -57,6 +57,7 @@ import org.elasticsearch.xpack.core.XPackField; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; @@ -270,6 +271,7 @@ public void testAuthenticateBothSupportSecondSucceeds() throws Exception { assertThat(result.getUser(), is(user)); assertThat(result.getLookedUpBy(), is(nullValue())); assertThat(result.getAuthenticatedBy(), is(notNullValue())); // TODO implement equals + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); assertThreadContextContainsAuthentication(result); setCompletedToTrue(completed); }, this::logAndFail)); @@ -289,6 +291,7 @@ public void testAuthenticateFirstNotSupportingSecondSucceeds() throws Exception service.authenticate("_action", message, (User)null, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), is(user)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); assertThreadContextContainsAuthentication(result); setCompletedToTrue(completed); }, this::logAndFail)); @@ -306,6 +309,7 @@ public void testAuthenticateCached() throws Exception { assertThat(result, notNullValue()); assertThat(result, is(authentication)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); verifyZeroInteractions(auditTrail); verifyZeroInteractions(firstRealm); verifyZeroInteractions(secondRealm); @@ -342,6 +346,7 @@ public void authenticationInContextAndHeader() throws Exception { assertThat(result, notNullValue()); assertThat(result.getUser(), is(user)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); String userStr = threadContext.getHeader(AuthenticationField.AUTHENTICATION_KEY); assertThat(userStr, notNullValue()); @@ -387,6 +392,7 @@ public void testAuthenticateTransportFallback() throws Exception { Authentication result = authenticateBlocking("_action", message, user1); assertThat(result, notNullValue()); assertThat(result.getUser(), sameInstance(user1)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.INTERNAL)); assertThreadContextContainsAuthentication(result); } @@ -432,6 +438,7 @@ public void testAuthenticateTransportSuccess() throws Exception { assertThat(result, notNullValue()); assertThat(result.getUser(), sameInstance(user)); assertThreadContextContainsAuthentication(result); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); setCompletedToTrue(completed); }, this::logAndFail)); @@ -450,6 +457,7 @@ public void testAuthenticateRestSuccess() throws Exception { service.authenticate(restRequest, ActionListener.wrap(authentication -> { assertThat(authentication, notNullValue()); assertThat(authentication.getUser(), sameInstance(user1)); + assertThat(authentication.getAuthenticationType(), is(AuthenticationType.REALM)); assertThreadContextContainsAuthentication(authentication); setCompletedToTrue(completed); }, this::logAndFail)); @@ -459,7 +467,7 @@ public void testAuthenticateRestSuccess() throws Exception { assertTrue(completed.get()); } - public void testAutheticateTransportContextAndHeader() throws Exception { + public void testAuthenticateTransportContextAndHeader() throws Exception { User user1 = new User("username", "r1", "r2"); when(firstRealm.token(threadContext)).thenReturn(token); when(firstRealm.supports(token)).thenReturn(true); @@ -469,9 +477,9 @@ public void testAutheticateTransportContextAndHeader() throws Exception { final SetOnce authHeaderRef = new SetOnce<>(); try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { service.authenticate("_action", message, SystemUser.INSTANCE, ActionListener.wrap(authentication -> { - assertThat(authentication, notNullValue()); assertThat(authentication.getUser(), sameInstance(user1)); + assertThat(authentication.getAuthenticationType(), is(AuthenticationType.REALM)); assertThreadContextContainsAuthentication(authentication); authRef.set(authentication); authHeaderRef.set(threadContext.getHeader(AuthenticationField.AUTHENTICATION_KEY)); @@ -530,6 +538,7 @@ public void testAutheticateTransportContextAndHeader() throws Exception { service.authenticate("_action", new InternalMessage(), SystemUser.INSTANCE, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), equalTo(user1)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); setCompletedToTrue(completed); }, this::logAndFail)); assertTrue(completed.get()); @@ -570,6 +579,7 @@ public void testAnonymousUserRest() throws Exception { assertThat(result, notNullValue()); assertThat(result.getUser(), sameInstance((Object) anonymousUser)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.ANONYMOUS)); assertThreadContextContainsAuthentication(result); String reqId = expectAuditRequestId(); verify(auditTrail).authenticationSuccess(reqId, "__anonymous", new AnonymousUser(settings), request); @@ -588,6 +598,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { Authentication result = authenticateBlocking("_action", message, null); assertThat(result, notNullValue()); assertThat(result.getUser(), sameInstance(anonymousUser)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.ANONYMOUS)); assertThreadContextContainsAuthentication(result); } @@ -604,6 +615,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { Authentication result = authenticateBlocking("_action", message, SystemUser.INSTANCE); assertThat(result, notNullValue()); assertThat(result.getUser(), sameInstance(SystemUser.INSTANCE)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.INTERNAL)); assertThreadContextContainsAuthentication(result); } @@ -790,6 +802,7 @@ public void testRunAsLookupSameRealm() throws Exception { final AtomicBoolean completed = new AtomicBoolean(false); ActionListener listener = ActionListener.wrap(result -> { assertThat(result, notNullValue()); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); User authenticated = result.getUser(); assertThat(authenticated.principal(), is("looked up user")); @@ -835,6 +848,7 @@ public void testRunAsLookupDifferentRealm() throws Exception { final AtomicBoolean completed = new AtomicBoolean(false); ActionListener listener = ActionListener.wrap(result -> { assertThat(result, notNullValue()); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); User authenticated = result.getUser(); assertThat(SystemUser.is(authenticated), is(false)); @@ -958,7 +972,7 @@ public void testAuthenticateWithToken() throws Exception { assertThat(result.getUser(), is(user)); assertThat(result.getLookedUpBy(), is(nullValue())); assertThat(result.getAuthenticatedBy(), is(notNullValue())); - assertEquals(expected, result); + assertThat(result.getAuthenticationType(), is(AuthenticationType.TOKEN)); setCompletedToTrue(completed); }, this::logAndFail)); } @@ -1115,6 +1129,7 @@ public void testApiKeyAuth() { threadContext.putHeader("Authorization", headerValue); final Authentication authentication = authenticateBlocking("_action", message, null); assertThat(authentication.getUser().principal(), is("johndoe")); + assertThat(authentication.getAuthenticationType(), is(AuthenticationType.API_KEY)); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java index 7926b44a38cb8..f8e8238975eb7 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java @@ -46,6 +46,7 @@ import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.TokenMetaData; import org.elasticsearch.xpack.core.security.user.User; @@ -169,6 +170,7 @@ public void testAttachAndGetToken() throws Exception { assertNotNull(token); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", randomFrom("Bearer ", "BEARER ", "bearer ") + tokenService.getUserTokenString(token)); @@ -177,7 +179,7 @@ public void testAttachAndGetToken() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { @@ -188,7 +190,7 @@ public void testAttachAndGetToken() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); anotherService.getAndValidateToken(requestContext, future); UserToken fromOtherService = future.get(); - assertEquals(authentication, fromOtherService.getAuthentication()); + assertAuthentication(authentication, fromOtherService.getAuthentication()); } } @@ -216,6 +218,7 @@ public void testRotateKey() throws Exception { assertNotNull(token); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); @@ -224,7 +227,7 @@ public void testRotateKey() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } rotateKeys(tokenService); @@ -232,7 +235,7 @@ public void testRotateKey() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } PlainActionFuture> newTokenFuture = new PlainActionFuture<>(); @@ -249,7 +252,7 @@ public void testRotateKey() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } } @@ -276,6 +279,7 @@ public void testKeyExchange() throws Exception { assertNotNull(token); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); @@ -283,7 +287,7 @@ public void testKeyExchange() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); otherTokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } rotateKeys(tokenService); @@ -307,6 +311,7 @@ public void testPruneKeys() throws Exception { assertNotNull(token); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); @@ -315,7 +320,7 @@ public void testPruneKeys() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } TokenMetaData metaData = tokenService.pruneKeys(randomIntBetween(0, 100)); tokenService.refreshMetaData(metaData); @@ -329,7 +334,7 @@ public void testPruneKeys() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } PlainActionFuture> newTokenFuture = new PlainActionFuture<>(); @@ -355,7 +360,7 @@ public void testPruneKeys() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } } @@ -369,6 +374,7 @@ public void testPassphraseWorks() throws Exception { assertNotNull(token); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); @@ -377,7 +383,7 @@ public void testPassphraseWorks() throws Exception { PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); UserToken serialized = future.get(); - assertEquals(authentication, serialized.getAuthentication()); + assertAuthentication(authentication, serialized.getAuthentication()); } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { @@ -467,6 +473,7 @@ public void testTokenExpiry() throws Exception { final UserToken token = tokenFuture.get().v1(); mockGetTokenFromId(token); mockCheckTokenInvalidationFromId(token); + authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); @@ -475,7 +482,7 @@ public void testTokenExpiry() throws Exception { // the clock is still frozen, so the cookie should be valid PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); - assertEquals(authentication, future.get().getAuthentication()); + assertAuthentication(authentication, future.get().getAuthentication()); } final TimeValue defaultExpiration = TokenService.TOKEN_EXPIRATION.get(Settings.EMPTY); @@ -485,7 +492,7 @@ public void testTokenExpiry() throws Exception { clock.fastForwardSeconds(fastForwardAmount); PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); - assertEquals(authentication, future.get().getAuthentication()); + assertAuthentication(authentication, future.get().getAuthentication()); } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { @@ -494,7 +501,7 @@ public void testTokenExpiry() throws Exception { clock.rewind(TimeValue.timeValueNanos(clock.instant().getNano())); // trim off nanoseconds since don't store them in the index PlainActionFuture future = new PlainActionFuture<>(); tokenService.getAndValidateToken(requestContext, future); - assertEquals(authentication, future.get().getAuthentication()); + assertAuthentication(authentication, future.get().getAuthentication()); } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { @@ -683,4 +690,12 @@ public static void mockCheckTokenInvalidationFromId(UserToken userToken, Client return Void.TYPE; }).when(client).multiGet(any(MultiGetRequest.class), any(ActionListener.class)); } + + public static void assertAuthentication(Authentication result, Authentication expected) { + assertEquals(expected.getUser(), result.getUser()); + assertEquals(expected.getAuthenticatedBy(), result.getAuthenticatedBy()); + assertEquals(expected.getLookedUpBy(), result.getLookedUpBy()); + assertEquals(expected.getMetadata(), result.getMetadata()); + assertEquals(AuthenticationType.TOKEN, result.getAuthenticationType()); + } } From 7c2e455c8c4b03bc032fdb99ab3a0774fab8a4bd Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad Date: Wed, 19 Dec 2018 10:44:13 +0530 Subject: [PATCH 09/26] Add seq num and primary term to GetResult constructor --- .../authc/AuthenticationServiceTests.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 8c9b884ae8c76..101b3d96ec634 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -44,6 +44,7 @@ import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; import org.elasticsearch.index.get.GetResult; +import org.elasticsearch.index.seqno.SequenceNumbers; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestStatus; @@ -1115,12 +1116,12 @@ public void testApiKeyAuth() { creatorMap.put("metadata", Collections.emptyMap()); creatorMap.put("realm", "auth realm"); source.put("creator", creatorMap); - GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 1L, true, + GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 0, 1, 1L, true, BytesReference.bytes(JsonXContent.contentBuilder().map(source)), Collections.emptyMap())); listener.onResponse(getResponse); } else { - listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), -1L, false, null, - Collections.emptyMap()))); + listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), + SequenceNumbers.UNASSIGNED_SEQ_NO, 1, -1L, false, null, Collections.emptyMap()))); } return Void.TYPE; }).when(client).get(any(GetRequest.class), any(ActionListener.class)); @@ -1153,12 +1154,12 @@ public void testExpiredApiKey() { creatorMap.put("metadata", Collections.emptyMap()); creatorMap.put("realm", "auth realm"); source.put("creator", creatorMap); - GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 1L, true, - BytesReference.bytes(JsonXContent.contentBuilder().map(source)), Collections.emptyMap())); + GetResponse getResponse = new GetResponse(new GetResult(request.index(), request.type(), request.id(), 0, 1, 1L, true, + BytesReference.bytes(JsonXContent.contentBuilder().map(source)), Collections.emptyMap())); listener.onResponse(getResponse); } else { - listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), -1L, false, null, - Collections.emptyMap()))); + listener.onResponse(new GetResponse(new GetResult(request.index(), request.type(), request.id(), + SequenceNumbers.UNASSIGNED_SEQ_NO, 1, -1L, false, null, Collections.emptyMap()))); } return Void.TYPE; }).when(client).get(any(GetRequest.class), any(ActionListener.class)); From dc8cd0117b5afb44835fb16de69e56133e1857b3 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Mon, 24 Dec 2018 08:37:49 +0530 Subject: [PATCH 10/26] [APIKey] Add rest action for create API Key (#36029) * [APIKey] Add create API key rest action This commit adds rest action for creating API keys. We use `java.time.Instant` to represent expiration for the API key. But when we serialize the response object to JSON the nanosecond precision is lost causing equality check failure after deserialization, to avoid this we are removing the nanosecond precision while creating the response object. Note: HLRC changes will be done in next PR. * Address review feedback - Add integ test for Rest API - create api key rest documentation - bootstrap checks similar to token service * Fix token -> api key service * Fix doc test * Fix comment wording * Fix comment * Correct test case * Address review comments * Add param description in the api docs * Add ApiKeySSLBootstrapCheck to bootstrap checks * rename priv name, remove unwanted code changes * correct priv names * Change API endpoint from _xpack to _security * Fix rest api spec test for api key --- x-pack/docs/build.gradle | 1 + x-pack/docs/en/rest-api/security.asciidoc | 10 ++ .../security/create-api-keys.asciidoc | 99 ++++++++++++++++ x-pack/plugin/build.gradle | 1 + .../security/action/CreateApiKeyRequest.java | 20 +++- .../action/CreateApiKeyRequestBuilder.java | 40 +++++++ .../security/action/CreateApiKeyResponse.java | 81 ++++++++++++- .../core/security/client/SecurityClient.java | 4 + .../CreateApiKeyRequestBuilderTests.java | 62 ++++++++++ .../action/CreateApiKeyResponseTests.java | 61 ++++++++-- .../security/ApiKeySSLBootstrapCheck.java | 37 ++++++ .../xpack/security/Security.java | 5 +- .../xpack/security/authc/ApiKeyService.java | 2 +- .../rest/action/RestCreateApiKeyAction.java | 56 +++++++++ .../ApiKeySSLBootstrapCheckTests.java | 32 +++++ .../security/TokenSSLBootsrapCheckTests.java | 9 +- .../action/RestCreateApiKeyActionTests.java | 111 ++++++++++++++++++ .../api/security.create_api_key.json | 22 ++++ .../rest-api-spec/test/api_key/10_basic.yml | 43 +++++++ 19 files changed, 677 insertions(+), 19 deletions(-) create mode 100644 x-pack/docs/en/rest-api/security/create-api-keys.asciidoc create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilderTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheck.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyAction.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheckTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyActionTests.java create mode 100644 x-pack/plugin/src/test/resources/rest-api-spec/api/security.create_api_key.json create mode 100644 x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml diff --git a/x-pack/docs/build.gradle b/x-pack/docs/build.gradle index de2400c0e85f0..ab01baa004d41 100644 --- a/x-pack/docs/build.gradle +++ b/x-pack/docs/build.gradle @@ -73,6 +73,7 @@ project.copyRestSpec.from(xpackResources) { } integTestCluster { setting 'xpack.security.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' setting 'xpack.security.authc.token.enabled', 'true' // Disable monitoring exporters for the docs tests setting 'xpack.monitoring.exporters._local.type', 'local' diff --git a/x-pack/docs/en/rest-api/security.asciidoc b/x-pack/docs/en/rest-api/security.asciidoc index 851bd2ba327b2..c17311ea12fa7 100644 --- a/x-pack/docs/en/rest-api/security.asciidoc +++ b/x-pack/docs/en/rest-api/security.asciidoc @@ -51,6 +51,15 @@ without requiring basic authentication: * <> * <> +[float] +[[security-api-keys]] +=== API Keys + +You can use the following API to create API keys for access +without requiring basic authentication: + +* <> + [float] [[security-user-apis]] === Users @@ -88,3 +97,4 @@ include::security/get-users.asciidoc[] include::security/has-privileges.asciidoc[] include::security/invalidate-tokens.asciidoc[] include::security/ssl.asciidoc[] +include::security/create-api-keys.asciidoc[] diff --git a/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc b/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc new file mode 100644 index 0000000000000..e4fa1be71d40e --- /dev/null +++ b/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc @@ -0,0 +1,99 @@ +[role="xpack"] +[[security-api-create-api-key]] +=== Create API Key API + +Creates an API key for access without requiring basic authentication. + +==== Request + +`POST /_security/api_key` +`PUT /_security/api_key` + +==== Description + +The API keys are created by the {es} API key service, which is automatically enabled +when you configure TLS on the HTTP interface. See <>. Alternatively, +you can explicitly enable the `xpack.security.authc.api_key.enabled` setting. When +you are running in production mode, a bootstrap check prevents you from enabling +the API key service unless you also enable TLS on the HTTP interface. + +A successful create API key API call returns a JSON structure that contains +the unique id, the name to identify API key, the API key and the expiration if +applicable for the API key in milliseconds. + +NOTE: By default API keys never expire. You can specify expiration at the time of +creation for the API keys. + +==== Request Body + +The following parameters can be specified in the body of a POST or PUT request: + +`name`:: +(string) Specifies the name for this API key. + +`role_descriptors`:: +(array-of-role-descriptor) Optional array of role descriptor for this API key. The role descriptor +must be a subset of permissions of the authenticated user. The structure of role +descriptor is same as the request for create role API. For more details on role +see <>. +If the role descriptors are not provided then permissions of the authenticated user are applied. + +`expiration`:: +(string) Optional expiration time for the API key. By default API keys never expire. + +==== Examples + +The following example creates an API key: + +[source, js] +------------------------------------------------------------ +POST /_security/api_key +{ + "name": "my-api-key", + "expiration": "1d", <1> + "role_descriptors": { <2> + "role-a": { + "cluster": ["all"], + "index": [ + { + "names": ["index-a*"], + "privileges": ["read"] + } + ] + }, + "role-b": { + "cluster": ["all"], + "index": [ + { + "names": ["index-b*"], + "privileges": ["all"] + } + ] + } + } +} +------------------------------------------------------------ +// CONSOLE +<1> optional expiration for the API key being generated. If expiration is not + provided then the API keys do not expire. +<2> optional role descriptors for this API key, if not provided then permissions + of authenticated user are applied. + +A successful call returns a JSON structure that provides +API key information. + +[source,js] +-------------------------------------------------- +{ + "id":"VuaCfGcBCdbkQm-e5aOx", <1> + "name":"my-api-key", + "expiration":1544068612110, <2> + "api_key":"ui2lp2axTNmsyakw9tvNnw" <3> +} +-------------------------------------------------- +// TESTRESPONSE[s/VuaCfGcBCdbkQm-e5aOx/$body.id/] +// TESTRESPONSE[s/1544068612110/$body.expiration/] +// TESTRESPONSE[s/ui2lp2axTNmsyakw9tvNnw/$body.api_key/] +<1> unique id for this API key +<2> optional expiration in milliseconds for this API key +<3> generated API key diff --git a/x-pack/plugin/build.gradle b/x-pack/plugin/build.gradle index b9cd464241fa2..340134d174249 100644 --- a/x-pack/plugin/build.gradle +++ b/x-pack/plugin/build.gradle @@ -133,6 +133,7 @@ integTestCluster { setting 'xpack.monitoring.exporters._local.type', 'local' setting 'xpack.monitoring.exporters._local.enabled', 'false' setting 'xpack.security.authc.token.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' setting 'xpack.security.transport.ssl.enabled', 'true' setting 'xpack.security.transport.ssl.key', nodeKey.name setting 'xpack.security.transport.ssl.certificate', nodeCert.name diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java index d8d5a3e1fc69b..28a872c2222dd 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java @@ -9,6 +9,7 @@ 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; @@ -27,14 +28,31 @@ * and optionally an expiration time and permission limitation can be provided. */ public final class CreateApiKeyRequest extends ActionRequest { + public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL; private String name; private TimeValue expiration; private List roleDescriptors = Collections.emptyList(); - private WriteRequest.RefreshPolicy refreshPolicy = WriteRequest.RefreshPolicy.WAIT_UNTIL; + private WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY; public CreateApiKeyRequest() {} + /** + * Create API Key request constructor + * @param name name for the API key + * @param roleDescriptors list of {@link RoleDescriptor}s + * @param expiration to specify expiration for the API key + */ + public CreateApiKeyRequest(String name, List roleDescriptors, @Nullable TimeValue expiration) { + if (Strings.hasText(name)) { + this.name = name; + } else { + throw new IllegalArgumentException("name must not be null or empty"); + } + this.roleDescriptors = Objects.requireNonNull(roleDescriptors, "role descriptors may not be null"); + this.expiration = expiration; + } + public CreateApiKeyRequest(StreamInput in) throws IOException { super(in); this.name = in.readString(); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java index 5423b02003592..1a711aa7d9a26 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java @@ -8,16 +8,44 @@ import org.elasticsearch.action.ActionRequestBuilder; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.client.ElasticsearchClient; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import java.io.IOException; +import java.io.InputStream; import java.util.List; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + /** * Request builder for populating a {@link CreateApiKeyRequest} */ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder { + @SuppressWarnings("unchecked") + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "api_key_request", false, (args, v) -> { + return new CreateApiKeyRequest((String) args[0], (List) args[1], + TimeValue.parseTimeValue((String) args[2], null, "expiration")); + }); + + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareNamedObjects(constructorArg(), (p, c, n) -> { + p.nextToken(); + return RoleDescriptor.parse(n, p, false); + }, new ParseField("role_descriptors")); + PARSER.declareString(optionalConstructorArg(), new ParseField("expiration")); + } + public CreateApiKeyRequestBuilder(ElasticsearchClient client) { super(client, CreateApiKeyAction.INSTANCE, new CreateApiKeyRequest()); } @@ -41,4 +69,16 @@ public CreateApiKeyRequestBuilder setRefreshPolicy(WriteRequest.RefreshPolicy re request.setRefreshPolicy(refreshPolicy); return this; } + + public CreateApiKeyRequestBuilder source(BytesReference source, XContentType xContentType) throws IOException { + final NamedXContentRegistry registry = NamedXContentRegistry.EMPTY; + try (InputStream stream = source.streamInput(); + XContentParser parser = xContentType.xContent().createParser(registry, LoggingDeprecationHandler.INSTANCE, stream)) { + CreateApiKeyRequest createApiKeyRequest = PARSER.parse(parser, null); + setName(createApiKeyRequest.getName()); + setRoleDescriptors(createApiKeyRequest.getRoleDescriptors()); + setExpiration(createApiKeyRequest.getExpiration()); + } + return this; + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java index eb327b459f055..a774413c3c4a2 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponse.java @@ -9,18 +9,37 @@ import org.elasticsearch.action.ActionResponse; import org.elasticsearch.common.CharArrays; import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; import java.io.IOException; import java.time.Instant; import java.util.Arrays; +import java.util.Objects; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; /** * Response for the successful creation of an api key */ -public final class CreateApiKeyResponse extends ActionResponse { +public final class CreateApiKeyResponse extends ActionResponse implements ToXContentObject { + + static ConstructingObjectParser PARSER = new ConstructingObjectParser<>("create_api_key_response", + args -> new CreateApiKeyResponse((String) args[0], (String) args[1], new SecureString((String) args[2]), + (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]))); + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareString(constructorArg(), new ParseField("id")); + PARSER.declareString(constructorArg(), new ParseField("api_key")); + PARSER.declareLong(optionalConstructorArg(), new ParseField("expiration")); + } private final String name; private final String id; @@ -31,7 +50,10 @@ public CreateApiKeyResponse(String name, String id, SecureString key, Instant ex this.name = name; this.id = id; this.key = key; - this.expiration = expiration; + // As we do not yet support the nanosecond precision when we serialize to JSON, + // here creating the 'Instant' of milliseconds precision. + // This Instant can then be used for date comparison. + this.expiration = (expiration != null) ? Instant.ofEpochMilli(expiration.toEpochMilli()): null; } public CreateApiKeyResponse(StreamInput in) throws IOException { @@ -67,6 +89,34 @@ public Instant getExpiration() { return expiration; } + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((expiration == null) ? 0 : expiration.hashCode()); + result = prime * result + Objects.hash(id, name, key); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final CreateApiKeyResponse other = (CreateApiKeyResponse) obj; + if (expiration == null) { + if (other.expiration != null) + return false; + } else if (!Objects.equals(expiration, other.expiration)) + return false; + return Objects.equals(id, other.id) + && Objects.equals(key, other.key) + && Objects.equals(name, other.name); + } + @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); @@ -88,4 +138,31 @@ public void writeTo(StreamOutput out) throws IOException { public void readFrom(StreamInput in) { throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); } + + public static CreateApiKeyResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject() + .field("id", id) + .field("name", name); + if (expiration != null) { + builder.field("expiration", expiration.toEpochMilli()); + } + byte[] charBytes = CharArrays.toUtf8Bytes(key.getChars()); + try { + builder.field("api_key").utf8Value(charBytes, 0, charBytes.length); + } finally { + Arrays.fill(charBytes, (byte) 0); + } + return builder.endObject(); + } + + @Override + public String toString() { + return "CreateApiKeyResponse [name=" + name + ", id=" + id + ", expiration=" + expiration + "]"; + } + } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java index d29658011b900..c67e05b222b4c 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java @@ -342,6 +342,10 @@ public CreateApiKeyRequestBuilder prepareCreateApiKey() { return new CreateApiKeyRequestBuilder(client); } + public CreateApiKeyRequestBuilder prepareCreateApiKey(BytesReference bytesReference, XContentType xContentType) throws IOException { + return new CreateApiKeyRequestBuilder(client).source(bytesReference, xContentType); + } + public void createApiKey(CreateApiKeyRequest request, ActionListener listener) { client.execute(CreateApiKeyAction.INSTANCE, request, listener); } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilderTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilderTests.java new file mode 100644 index 0000000000000..fb4f87089e8e7 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilderTests.java @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.IndicesPrivileges; + +import java.io.IOException; +import java.util.List; + +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; + +public class CreateApiKeyRequestBuilderTests extends ESTestCase { + + public void testParserAndCreateApiRequestBuilder() throws IOException { + boolean withExpiration = randomBoolean(); + final String json = "{ \"name\" : \"my-api-key\", " + + ((withExpiration) ? " \"expiration\": \"1d\", " : "") + +" \"role_descriptors\": { \"role-a\": {\"cluster\":[\"a-1\", \"a-2\"]," + + " \"index\": [{\"names\": [\"indx-a\"], \"privileges\": [\"read\"] }] }, " + + " \"role-b\": {\"cluster\":[\"b\"]," + + " \"index\": [{\"names\": [\"indx-b\"], \"privileges\": [\"read\"] }] } " + + "} }"; + final BytesArray source = new BytesArray(json); + final NodeClient mockClient = mock(NodeClient.class); + final CreateApiKeyRequest request = new CreateApiKeyRequestBuilder(mockClient).source(source, XContentType.JSON).request(); + final List actualRoleDescriptors = request.getRoleDescriptors(); + assertThat(request.getName(), equalTo("my-api-key")); + assertThat(actualRoleDescriptors.size(), is(2)); + for (RoleDescriptor rd : actualRoleDescriptors) { + String[] clusters = null; + IndicesPrivileges indicesPrivileges = null; + if (rd.getName().equals("role-a")) { + clusters = new String[] { "a-1", "a-2" }; + indicesPrivileges = RoleDescriptor.IndicesPrivileges.builder().indices("indx-a").privileges("read").build(); + } else if (rd.getName().equals("role-b")){ + clusters = new String[] { "b" }; + indicesPrivileges = RoleDescriptor.IndicesPrivileges.builder().indices("indx-b").privileges("read").build(); + } else { + fail("unexpected role name"); + } + assertThat(rd.getClusterPrivileges(), arrayContainingInAnyOrder(clusters)); + assertThat(rd.getIndicesPrivileges(), + arrayContainingInAnyOrder(indicesPrivileges)); + } + if (withExpiration) { + assertThat(request.getExpiration(), equalTo(TimeValue.parseTimeValue("1d", "expiration"))); + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java index 2fe493823f69f..20ff4bc251d15 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyResponseTests.java @@ -10,31 +10,72 @@ import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.settings.SecureString; -import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.test.AbstractXContentTestCase; +import org.elasticsearch.test.EqualsHashCodeTestUtils; import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; -public class CreateApiKeyResponseTests extends ESTestCase { +import static org.hamcrest.Matchers.equalTo; - public void testSerialization() throws IOException { +public class CreateApiKeyResponseTests extends AbstractXContentTestCase { + + @Override + protected CreateApiKeyResponse doParseInstance(XContentParser parser) throws IOException { + return CreateApiKeyResponse.fromXContent(parser); + } + + @Override + protected CreateApiKeyResponse createTestInstance() { final String name = randomAlphaOfLengthBetween(1, 256); final SecureString key = new SecureString(UUIDs.randomBase64UUID().toCharArray()); final Instant expiration = randomBoolean() ? Instant.now().plus(7L, ChronoUnit.DAYS) : null; final String id = randomAlphaOfLength(100); + return new CreateApiKeyResponse(name, id, key, expiration); + } + + @Override + protected boolean supportsUnknownFields() { + return false; + } - final CreateApiKeyResponse response = new CreateApiKeyResponse(name, id, key, expiration); + public void testSerialization() throws IOException { + final CreateApiKeyResponse response = createTestInstance(); try (BytesStreamOutput out = new BytesStreamOutput()) { response.writeTo(out); - - try (StreamInput in = out.bytes().streamInput()) { + try (StreamInput in = out.bytes().streamInput()) { CreateApiKeyResponse serialized = new CreateApiKeyResponse(in); - assertEquals(name, serialized.getName()); - assertEquals(id, serialized.getId()); - assertEquals(key, serialized.getKey()); - assertEquals(expiration, serialized.getExpiration()); + assertThat(serialized, equalTo(response)); } } } + + public void testEqualsHashCode() { + CreateApiKeyResponse createApiKeyResponse = createTestInstance(); + + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyResponse, (original) -> { + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), original.getExpiration()); + }); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyResponse, (original) -> { + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), original.getExpiration()); + }, CreateApiKeyResponseTests::mutateTestItem); + } + + private static CreateApiKeyResponse mutateTestItem(CreateApiKeyResponse original) { + switch (randomIntBetween(0, 3)) { + case 0: + return new CreateApiKeyResponse(randomAlphaOfLength(5), original.getId(), original.getKey(), original.getExpiration()); + case 1: + return new CreateApiKeyResponse(original.getName(), randomAlphaOfLength(5), original.getKey(), original.getExpiration()); + case 2: + return new CreateApiKeyResponse(original.getName(), original.getId(), new SecureString(UUIDs.randomBase64UUID().toCharArray()), + original.getExpiration()); + case 3: + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), Instant.now()); + default: + return new CreateApiKeyResponse(randomAlphaOfLength(5), original.getId(), original.getKey(), original.getExpiration()); + } + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheck.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheck.java new file mode 100644 index 0000000000000..26eec509210b6 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheck.java @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security; + +import org.elasticsearch.bootstrap.BootstrapCheck; +import org.elasticsearch.bootstrap.BootstrapContext; +import org.elasticsearch.xpack.core.XPackSettings; + +import java.util.Locale; + +/** + * Bootstrap check to ensure that the user has enabled HTTPS when using the api key service + */ +public final class ApiKeySSLBootstrapCheck implements BootstrapCheck { + + @Override + public BootstrapCheckResult check(BootstrapContext context) { + final Boolean httpsEnabled = XPackSettings.HTTP_SSL_ENABLED.get(context.settings()); + final Boolean apiKeyServiceEnabled = XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.get(context.settings()); + if (httpsEnabled == false && apiKeyServiceEnabled) { + final String message = String.format( + Locale.ROOT, + "HTTPS is required in order to use the API key service; " + + "please enable HTTPS using the [%s] setting or disable the API key service using the [%s] setting", + XPackSettings.HTTP_SSL_ENABLED.getKey(), + XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey()); + return BootstrapCheckResult.failure(message); + } + return BootstrapCheckResult.success(); + } + + +} 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 0328f512cd6fa..4795588881de1 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 @@ -194,6 +194,7 @@ import org.elasticsearch.xpack.security.ingest.SetSecurityUserProcessor; import org.elasticsearch.xpack.security.rest.SecurityRestFilter; import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction; +import org.elasticsearch.xpack.security.rest.action.RestCreateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestDeletePrivilegesAction; @@ -308,6 +309,7 @@ public Security(Settings settings, final Path configPath) { // fetched final List checks = new ArrayList<>(); checks.addAll(Arrays.asList( + new ApiKeySSLBootstrapCheck(), new TokenSSLBootstrapCheck(), new PkiRealmBootstrapCheck(getSslService()), new TLSLicenseBootstrapCheck(), @@ -798,7 +800,8 @@ public List getRestHandlers(Settings settings, RestController restC new RestSamlInvalidateSessionAction(settings, restController, getLicenseState()), new RestGetPrivilegesAction(settings, restController, getLicenseState()), new RestPutPrivilegesAction(settings, restController, getLicenseState()), - new RestDeletePrivilegesAction(settings, restController, getLicenseState()) + new RestDeletePrivilegesAction(settings, restController, getLicenseState()), + new RestCreateApiKeyAction(settings, restController, getLicenseState()) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 1c860766b4751..d94c309ccb4fd 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -337,7 +337,7 @@ private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { private void ensureEnabled() { if (enabled == false) { - throw new IllegalStateException("tokens are not enabled"); + throw new IllegalStateException("api keys are not enabled"); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyAction.java new file mode 100644 index 0000000000000..2e3ced0d8933f --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyAction.java @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action; + +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.client.SecurityClient; + +import java.io.IOException; + +/** + * Rest action to create an API key + */ +public final class RestCreateApiKeyAction extends SecurityBaseRestHandler { + + /** + * @param settings the node's settings + * @param licenseState the license state that will be used to determine if + * security is licensed + */ + public RestCreateApiKeyAction(Settings settings, RestController controller, XPackLicenseState licenseState) { + super(settings, licenseState); + controller.registerHandler(RestRequest.Method.POST, "/_security/api_key", this); + controller.registerHandler(RestRequest.Method.PUT, "/_security/api_key", this); + } + + @Override + public String getName() { + return "xpack_security_create_api_key"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException { + try (XContentParser parser = request.contentParser()) { + String refresh = request.param("refresh"); + CreateApiKeyRequestBuilder builder = new SecurityClient(client) + .prepareCreateApiKey(request.requiredContent(), request.getXContentType()) + .setRefreshPolicy((refresh != null) ? WriteRequest.RefreshPolicy.parse(request.param("refresh")) + : CreateApiKeyRequest.DEFAULT_REFRESH_POLICY); + return channel -> builder.execute(new RestToXContentListener(channel)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheckTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheckTests.java new file mode 100644 index 0000000000000..aa4f8d51980eb --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/ApiKeySSLBootstrapCheckTests.java @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.AbstractBootstrapCheckTestCase; +import org.elasticsearch.xpack.core.XPackSettings; + +public class ApiKeySSLBootstrapCheckTests extends AbstractBootstrapCheckTestCase { + + public void testApiKeySSLBootstrapCheck() { + Settings settings = Settings.EMPTY; + + assertTrue(new ApiKeySSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); + + settings = Settings.builder().put(XPackSettings.HTTP_SSL_ENABLED.getKey(), true).build(); + assertTrue(new ApiKeySSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); + + // XPackSettings.HTTP_SSL_ENABLED default false + settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build(); + assertTrue(new ApiKeySSLBootstrapCheck().check(createTestContext(settings, null)).isFailure()); + + settings = Settings.builder() + .put(XPackSettings.HTTP_SSL_ENABLED.getKey(), true) + .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build(); + assertTrue(new ApiKeySSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/TokenSSLBootsrapCheckTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/TokenSSLBootsrapCheckTests.java index 6d252e0035c9e..09c1315123c2c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/TokenSSLBootsrapCheckTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/TokenSSLBootsrapCheckTests.java @@ -14,18 +14,19 @@ public class TokenSSLBootsrapCheckTests extends AbstractBootstrapCheckTestCase { public void testTokenSSLBootstrapCheck() { Settings settings = Settings.EMPTY; - assertFalse(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isFailure()); + assertTrue(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); settings = Settings.builder().put(XPackSettings.HTTP_SSL_ENABLED.getKey(), true).build(); - assertFalse(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isFailure()); + assertTrue(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); // XPackSettings.HTTP_SSL_ENABLED default false settings = Settings.builder().put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true).build(); assertTrue(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isFailure()); settings = Settings.builder() - .put(XPackSettings.HTTP_SSL_ENABLED.getKey(), false) + .put(XPackSettings.HTTP_SSL_ENABLED.getKey(), true) .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true).build(); - assertTrue(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isFailure()); + assertTrue(new TokenSSLBootstrapCheck().check(createTestContext(settings, null)).isSuccess()); } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyActionTests.java new file mode 100644 index 0000000000000..1b1b0fe8f0f1a --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestCreateApiKeyActionTests.java @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action; + +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.Action; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.AbstractRestChannel; +import org.elasticsearch.rest.RestChannel; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestResponse; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class RestCreateApiKeyActionTests extends ESTestCase { + private final XPackLicenseState mockLicenseState = mock(XPackLicenseState.class); + private final RestController mockRestController = mock(RestController.class); + private Settings settings = null; + private ThreadPool threadPool = null; + + @Override + public void setUp() throws Exception { + super.setUp(); + settings = Settings.builder() + .put("path.home", createTempDir().toString()) + .put("node.name", "test-" + getTestName()) + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .build(); + threadPool = new ThreadPool(settings); + when(mockLicenseState.isSecurityAvailable()).thenReturn(true); + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + terminate(threadPool); + } + + @SuppressWarnings({ "unchecked"}) + public void testCreateApiKeyApi() throws Exception { + final String json = "{ \"name\" : \"my-api-key\", \"role_descriptors\": { \"role-a\": {\"cluster\":[\"a-1\", \"a-2\"]} } }"; + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY) + .withContent(new BytesArray(json), XContentType.JSON) + .withParams(Collections.singletonMap("refresh", randomFrom("false", "true", "wait_for"))) + .build(); + + final SetOnce responseSetOnce = new SetOnce<>(); + final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) { + @Override + public void sendResponse(RestResponse restResponse) { + responseSetOnce.set(restResponse); + } + }; + + final CreateApiKeyResponse expected = new CreateApiKeyResponse("my-api-key", UUID.randomUUID().toString(), + new SecureString(randomAlphaOfLength(5)), Instant.now().plus(Duration.ofHours(5))); + + try (NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute(Action action, Request request, + ActionListener listener) { + CreateApiKeyRequest createApiKeyRequest = (CreateApiKeyRequest) request; + @SuppressWarnings("unchecked") + RestToXContentListener actionListener = (RestToXContentListener) listener; + if (createApiKeyRequest.getName().equals("my-api-key")) { + actionListener.onResponse(expected); + } else { + listener.onFailure(new ElasticsearchSecurityException("encountered an error while creating API key")); + } + } + }) { + final RestCreateApiKeyAction restCreateApiKeyAction = new RestCreateApiKeyAction(Settings.EMPTY, mockRestController, + mockLicenseState); + restCreateApiKeyAction.handleRequest(restRequest, restChannel, client); + + final RestResponse restResponse = responseSetOnce.get(); + assertNotNull(restResponse); + assertThat(CreateApiKeyResponse.fromXContent(createParser(XContentType.JSON.xContent(), restResponse.content())), + equalTo(expected)); + } + } + +} diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/api/security.create_api_key.json b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.create_api_key.json new file mode 100644 index 0000000000000..30b5b30a52b61 --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.create_api_key.json @@ -0,0 +1,22 @@ +{ + "security.create_api_key": { + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html", + "methods": [ "PUT", "POST" ], + "url": { + "path": "/_security/api_key", + "paths": [ "/_security/api_key" ], + "parts": {}, + "params": { + "refresh": { + "type" : "enum", + "options": ["true", "false", "wait_for"], + "description" : "If `true` (the default) then refresh the affected shards to make this operation visible to search, if `wait_for` then wait for a refresh to make this operation visible to search, if `false` then do nothing with refreshes." + } + } + }, + "body": { + "description" : "The api key request to create an API key", + "required" : true + } + } +} diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml new file mode 100644 index 0000000000000..4737cc003ca11 --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml @@ -0,0 +1,43 @@ +--- +setup: + - skip: + features: headers + + - do: + cluster.health: + wait_for_status: yellow + +--- +"Test create api key": + + - do: + security.create_api_key: + body: > + { + "name": "my-api-key", + "expiration": "1d", + "role_descriptors": { + "role-a": { + "cluster": ["a-1"], + "index": [ + { + "names": ["index-a"], + "privileges": ["read"] + } + ] + }, + "role-b": { + "cluster": ["b-1"], + "index": [ + { + "names": ["index-b"], + "privileges": ["all"] + } + ] + } + } + } + - match: { name: "my-api-key" } + - is_true: id + - is_true: api_key + - is_true: expiration From 2fba9bf56191193afd4d58a5f41a4020c06c4713 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad Date: Wed, 9 Jan 2019 07:45:14 +1100 Subject: [PATCH 11/26] Fix merge failures from master --- .../xpack/security/authc/AuthenticationServiceTests.java | 4 ++-- .../xpack/security/authc/TokenServiceTests.java | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 23cace68dbbde..169956794e36f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -13,6 +13,7 @@ import org.elasticsearch.action.get.GetAction; import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetRequestBuilder; +import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.get.MultiGetAction; import org.elasticsearch.action.get.MultiGetRequestBuilder; import org.elasticsearch.action.index.IndexAction; @@ -27,7 +28,6 @@ import org.elasticsearch.common.SuppressForbidden; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.bytes.BytesReference; -import org.elasticsearch.common.collect.MapBuilder; import org.elasticsearch.common.collect.Tuple; import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.io.stream.StreamInput; @@ -1060,8 +1060,8 @@ public void testApiKeyAuthInvalidHeader() { threadContext.putHeader("Authorization", invalidHeader); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> authenticateBlocking("_action", message, null)); - assertThat(e.getMessage(), containsString("missing authentication token")); assertEquals(RestStatus.UNAUTHORIZED, e.status()); + assertThat(e.getMessage(), containsString("missing authentication credentials")); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java index 3173da9791061..24230a2c7a227 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java @@ -147,7 +147,6 @@ public void testAttachAndGetToken() throws Exception { final UserToken token = tokenFuture.get().v1(); assertNotNull(token); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); @@ -195,7 +194,6 @@ public void testRotateKey() throws Exception { final UserToken token = tokenFuture.get().v1(); assertNotNull(token); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); @@ -256,7 +254,6 @@ public void testKeyExchange() throws Exception { final UserToken token = tokenFuture.get().v1(); assertNotNull(token); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); @@ -288,7 +285,6 @@ public void testPruneKeys() throws Exception { final UserToken token = tokenFuture.get().v1(); assertNotNull(token); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); @@ -351,7 +347,6 @@ public void testPassphraseWorks() throws Exception { final UserToken token = tokenFuture.get().v1(); assertNotNull(token); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); @@ -427,7 +422,6 @@ public void testTokenExpiry() throws Exception { tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap(), true); final UserToken token = tokenFuture.get().v1(); mockGetTokenFromId(token, false); - mockCheckTokenInvalidationFromId(token); authentication = token.getAuthentication(); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); From 1a12c59cd0060fcfe6e05e11ba34ee89b7cdbc69 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad Date: Tue, 15 Jan 2019 12:18:40 +1100 Subject: [PATCH 12/26] Fix merge issue --- .../xpack/security/authc/AuthenticationServiceTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 5e54ba76d9baa..daa707b7c88db 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -363,7 +363,7 @@ public void testAuthenticateSmartRealmOrderingDisabled() { .build(); service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(Settings.EMPTY), - tokenService); + tokenService, apiKeyService); User user = new User("_username", "r1"); when(firstRealm.supports(token)).thenReturn(true); mockAuthenticate(firstRealm, token, null); From 23eff70dab9933a3a76efe16d08f99ac173b557e Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Thu, 17 Jan 2019 15:39:30 +1100 Subject: [PATCH 13/26] Add invalidate API key transport action (#37399) This commit adds transport action for invalidating API keys. Users can request invalidation of api keys for:- - all the keys for a given username - all the keys for a given realm name - all the keys for a given realm and username - for a given api key id - for a given api key name This is similar to invalidate tokens API. Follow up PRs: Rest action and HLRC changes for the invalidate API. --- .../xpack/core/XPackClientPlugin.java | 2 + .../action/InvalidateApiKeyAction.java | 33 ++ .../action/InvalidateApiKeyRequest.java | 146 +++++++ .../action/InvalidateApiKeyResponse.java | 118 +++++ .../core/security/client/SecurityClient.java | 8 + .../resources/security-index-template.json | 3 + .../action/InvalidateApiKeyRequestTests.java | 118 +++++ .../action/InvalidateApiKeyResponseTests.java | 88 ++++ .../xpack/security/Security.java | 5 +- .../TransportInvalidateApiKeyAction.java | 44 ++ .../xpack/security/authc/ApiKeyService.java | 413 ++++++++++++++---- .../security/authc/ApiKeyIntegTests.java | 129 ++++++ .../security/authc/ApiKeyServiceTests.java | 15 +- .../authc/AuthenticationServiceTests.java | 2 + 14 files changed, 1045 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponseTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java index 5af4e2c6a868b..544866356b9d9 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java @@ -135,6 +135,7 @@ import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction; import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction; import org.elasticsearch.xpack.core.security.action.role.DeleteRoleAction; @@ -313,6 +314,7 @@ public List> getClientActions() { GetCertificateInfoAction.INSTANCE, RefreshTokenAction.INSTANCE, CreateApiKeyAction.INSTANCE, + InvalidateApiKeyAction.INSTANCE, // upgrade IndexUpgradeInfoAction.INSTANCE, IndexUpgradeAction.INSTANCE, diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyAction.java new file mode 100644 index 0000000000000..0f5c7e66e724c --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyAction.java @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.Action; +import org.elasticsearch.common.io.stream.Writeable; + +/** + * Action for invalidating API key + */ +public final class InvalidateApiKeyAction extends Action { + + public static final String NAME = "cluster:admin/xpack/security/api_key/invalidate"; + public static final InvalidateApiKeyAction INSTANCE = new InvalidateApiKeyAction(); + + private InvalidateApiKeyAction() { + super(NAME); + } + + @Override + public InvalidateApiKeyResponse newResponse() { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public Writeable.Reader getResponseReader() { + return InvalidateApiKeyResponse::new; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java new file mode 100644 index 0000000000000..b60267d603155 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +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 java.io.IOException; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +/** + * Request for invalidating API key(s) so that it can no longer be used + */ +public final class InvalidateApiKeyRequest extends ActionRequest { + + private final String realmName; + private final String userName; + private final String apiKeyId; + private final String apiKeyName; + + public InvalidateApiKeyRequest() { + this(null, null, null, null); + } + + public InvalidateApiKeyRequest(StreamInput in) throws IOException { + super(in); + realmName = in.readOptionalString(); + userName = in.readOptionalString(); + apiKeyId = in.readOptionalString(); + apiKeyName = in.readOptionalString(); + } + + private InvalidateApiKeyRequest(@Nullable String realmName, @Nullable String userName, @Nullable String apiKeyId, + @Nullable String apiKeyName) { + this.realmName = realmName; + this.userName = userName; + this.apiKeyId = apiKeyId; + this.apiKeyName = apiKeyName; + } + + public String getRealmName() { + return realmName; + } + + public String getUserName() { + return userName; + } + + public String getApiKeyId() { + return apiKeyId; + } + + public String getApiKeyName() { + return apiKeyName; + } + + /** + * Creates invalidate api key request for given realm name + * @param realmName realm name + * @return {@link InvalidateApiKeyRequest} + */ + public static InvalidateApiKeyRequest usingRealmName(String realmName) { + return new InvalidateApiKeyRequest(realmName, null, null, null); + } + + /** + * Creates invalidate API key request for given user name + * @param userName user name + * @return {@link InvalidateApiKeyRequest} + */ + public static InvalidateApiKeyRequest usingUserName(String userName) { + return new InvalidateApiKeyRequest(null, userName, null, null); + } + + /** + * Creates invalidate API key request for given realm and user name + * @param realmName realm name + * @param userName user name + * @return {@link InvalidateApiKeyRequest} + */ + public static InvalidateApiKeyRequest usingRealmAndUserName(String realmName, String userName) { + return new InvalidateApiKeyRequest(realmName, userName, null, null); + } + + /** + * Creates invalidate API key request for given api key id + * @param apiKeyId api key id + * @return {@link InvalidateApiKeyRequest} + */ + public static InvalidateApiKeyRequest usingApiKeyId(String apiKeyId) { + return new InvalidateApiKeyRequest(null, null, apiKeyId, null); + } + + /** + * Creates invalidate api key request for given api key name + * @param apiKeyName api key name + * @return {@link InvalidateApiKeyRequest} + */ + public static InvalidateApiKeyRequest usingApiKeyName(String apiKeyName) { + return new InvalidateApiKeyRequest(null, null, null, apiKeyName); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false && Strings.hasText(apiKeyId) == false + && Strings.hasText(apiKeyName) == false) { + validationException = addValidationError("One of [api key id, api key name, username, realm name] must be specified", null); + } + if (Strings.hasText(realmName) || Strings.hasText(userName)) { + if (Strings.hasText(apiKeyId)) { + validationException = addValidationError("api key id must not be specified when username or realm name is specified", null); + } + if (Strings.hasText(apiKeyName)) { + validationException = addValidationError("api key name must not be specified when username or realm name is specified", + validationException); + } + } else if (Strings.hasText(apiKeyId) && Strings.hasText(apiKeyName)) { + validationException = addValidationError("api key name must not be specified when api key id is specified", null); + } + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalString(realmName); + out.writeOptionalString(userName); + out.writeOptionalString(apiKeyId); + out.writeOptionalString(apiKeyName); + } + + @Override + public void readFrom(StreamInput in) throws IOException { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java new file mode 100644 index 0000000000000..1db4f25e96c2f --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionResponse; +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.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Response for invalidation of one or more API keys result.
+ * The result contains information about: + *
    + *
  • API key ids that were actually invalidated
  • + *
  • API key ids that were not invalidated in this request because they were already invalidated
  • + *
  • how many errors were encountered while invalidating API keys and the error details
  • + *
+ */ +public final class InvalidateApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable { + + private final List invalidatedApiKeys; + private final List previouslyInvalidatedApiKeys; + private final List errors; + + public InvalidateApiKeyResponse(StreamInput in) throws IOException { + super(in); + this.invalidatedApiKeys = in.readList(StreamInput::readString); + this.previouslyInvalidatedApiKeys = in.readList(StreamInput::readString); + this.errors = in.readList(StreamInput::readException); + } + + /** + * Constructor for API keys invalidation response + * @param invalidatedApiKeys list of invalidated API key ids + * @param previouslyInvalidatedApiKeys list of previously invalidated API key ids + * @param errors list of encountered errors while invalidating API keys + */ + public InvalidateApiKeyResponse(List invalidatedApiKeys, List previouslyInvalidatedApiKeys, + @Nullable List errors) { + this.invalidatedApiKeys = Objects.requireNonNull(invalidatedApiKeys, "invalidated_api_keys must be provided"); + this.previouslyInvalidatedApiKeys = Objects.requireNonNull(previouslyInvalidatedApiKeys, + "previously_invalidated_api_keys must be provided"); + if (null != errors) { + this.errors = errors; + } else { + this.errors = Collections.emptyList(); + } + } + + public static InvalidateApiKeyResponse emptyResponse() { + return new InvalidateApiKeyResponse(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + } + + public List getInvalidatedApiKeys() { + return invalidatedApiKeys; + } + + public List getPreviouslyInvalidatedApiKeys() { + return previouslyInvalidatedApiKeys; + } + + public List getErrors() { + return errors; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject() + .array("invalidated_api_keys", invalidatedApiKeys.toArray(Strings.EMPTY_ARRAY)) + .array("previously_invalidated_api_keys", previouslyInvalidatedApiKeys.toArray(Strings.EMPTY_ARRAY)) + .field("error_count", errors.size()); + if (errors.isEmpty() == false) { + builder.field("error_details"); + builder.startArray(); + for (ElasticsearchException e : errors) { + builder.startObject(); + ElasticsearchException.generateThrowableXContent(builder, params, e); + builder.endObject(); + } + builder.endArray(); + } + return builder.endObject(); + } + + @Override + public void readFrom(StreamInput in) throws IOException { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeStringList(invalidatedApiKeys); + out.writeStringList(previouslyInvalidatedApiKeys); + out.writeCollection(errors, StreamOutput::writeException); + } + + @Override + public String toString() { + return "ApiKeysInvalidationResult [invalidatedApiKeys=" + invalidatedApiKeys + ", previouslyInvalidatedApiKeys=" + + previouslyInvalidatedApiKeys + ", errors=" + errors + "]"; + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java index c67e05b222b4c..58508651e2271 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java @@ -14,6 +14,9 @@ import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesRequestBuilder; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; @@ -338,6 +341,7 @@ public void invalidateToken(InvalidateTokenRequest request, ActionListener listener) { + client.execute(InvalidateApiKeyAction.INSTANCE, request, listener); + } + public SamlAuthenticateRequestBuilder prepareSamlAuthenticate(byte[] xmlContent, List validIds) { final SamlAuthenticateRequestBuilder builder = new SamlAuthenticateRequestBuilder(client); builder.saml(xmlContent); diff --git a/x-pack/plugin/core/src/main/resources/security-index-template.json b/x-pack/plugin/core/src/main/resources/security-index-template.json index 45d9b96d69fdf..de0322f873225 100644 --- a/x-pack/plugin/core/src/main/resources/security-index-template.json +++ b/x-pack/plugin/core/src/main/resources/security-index-template.json @@ -154,6 +154,9 @@ "index": false, "doc_values": false }, + "api_key_invalidated" : { + "type" : "boolean" + }, "role_descriptors" : { "type" : "object", "enabled": false diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java new file mode 100644 index 0000000000000..3fdd98e5c5183 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.InputStreamStreamInput; +import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.test.ESTestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.hamcrest.Matchers.containsString; + +public class InvalidateApiKeyRequestTests extends ESTestCase { + + public void testRequestValidation() { + InvalidateApiKeyRequest request = InvalidateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(5)); + ActionRequestValidationException ve = request.validate(); + assertNull(ve); + request = InvalidateApiKeyRequest.usingApiKeyName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = InvalidateApiKeyRequest.usingRealmName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = InvalidateApiKeyRequest.usingUserName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = InvalidateApiKeyRequest.usingRealmAndUserName(randomAlphaOfLength(5), randomAlphaOfLength(7)); + ve = request.validate(); + assertNull(ve); + } + + public void testRequestValidationFailureScenarios() throws IOException { + class Dummy extends ActionRequest { + String realm; + String user; + String apiKeyId; + String apiKeyName; + + Dummy(String[] a) { + realm = a[0]; + user = a[1]; + apiKeyId = a[2]; + apiKeyName = a[3]; + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalString(realm); + out.writeOptionalString(user); + out.writeOptionalString(apiKeyId); + out.writeOptionalString(apiKeyName); + } + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { + Dummy d = new Dummy(new String[] { "realm", "user", "api-kid", "api-kname" }); + d.writeTo(osso); + + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + InputStreamStreamInput issi = new InputStreamStreamInput(bis); + + InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); + ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertEquals(2, ve.validationErrors().size()); + assertThat(ve.validationErrors().get(0), + containsString("api key id must not be specified when username or realm name is specified")); + assertThat(ve.validationErrors().get(1), + containsString("api key name must not be specified when username or realm name is specified")); + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { + Dummy d = new Dummy(new String[] { null, null, "api-kid", "api-kname" }); + d.writeTo(osso); + + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + InputStreamStreamInput issi = new InputStreamStreamInput(bis); + + InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); + ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertEquals(1, ve.validationErrors().size()); + assertThat(ve.validationErrors().get(0), + containsString("api key name must not be specified when api key id is specified")); + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { + Dummy d = new Dummy(new String[] { "realm", null, null, "api-kname" }); + d.writeTo(osso); + + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + InputStreamStreamInput issi = new InputStreamStreamInput(bis); + + InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); + ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertEquals(1, ve.validationErrors().size()); + assertThat(ve.validationErrors().get(0), + containsString("api key name must not be specified when username or realm name is specified")); + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponseTests.java new file mode 100644 index 0000000000000..f4606a4f20f1b --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponseTests.java @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class InvalidateApiKeyResponseTests extends ESTestCase { + + public void testSerialization() throws IOException { + InvalidateApiKeyResponse response = new InvalidateApiKeyResponse(Arrays.asList("api-key-id-1"), + Arrays.asList("api-key-id-2", "api-key-id-3"), + Arrays.asList(new ElasticsearchException("error1"), + new ElasticsearchException("error2"))); + try (BytesStreamOutput output = new BytesStreamOutput()) { + response.writeTo(output); + try (StreamInput input = output.bytes().streamInput()) { + InvalidateApiKeyResponse serialized = new InvalidateApiKeyResponse(input); + assertThat(serialized.getInvalidatedApiKeys(), equalTo(response.getInvalidatedApiKeys())); + assertThat(serialized.getPreviouslyInvalidatedApiKeys(), + equalTo(response.getPreviouslyInvalidatedApiKeys())); + assertThat(serialized.getErrors().size(), equalTo(response.getErrors().size())); + assertThat(serialized.getErrors().get(0).toString(), containsString("error1")); + assertThat(serialized.getErrors().get(1).toString(), containsString("error2")); + } + } + + response = new InvalidateApiKeyResponse(Arrays.asList(generateRandomStringArray(20, 15, false)), + Arrays.asList(generateRandomStringArray(20, 15, false)), + Collections.emptyList()); + try (BytesStreamOutput output = new BytesStreamOutput()) { + response.writeTo(output); + try (StreamInput input = output.bytes().streamInput()) { + InvalidateApiKeyResponse serialized = new InvalidateApiKeyResponse(input); + assertThat(serialized.getInvalidatedApiKeys(), equalTo(response.getInvalidatedApiKeys())); + assertThat(serialized.getPreviouslyInvalidatedApiKeys(), + equalTo(response.getPreviouslyInvalidatedApiKeys())); + assertThat(serialized.getErrors().size(), equalTo(response.getErrors().size())); + } + } + } + + public void testToXContent() throws IOException { + InvalidateApiKeyResponse response = new InvalidateApiKeyResponse(Arrays.asList("api-key-id-1"), + Arrays.asList("api-key-id-2", "api-key-id-3"), + Arrays.asList(new ElasticsearchException("error1", new IllegalArgumentException("msg - 1")), + new ElasticsearchException("error2", new IllegalArgumentException("msg - 2")))); + XContentBuilder builder = XContentFactory.jsonBuilder(); + response.toXContent(builder, ToXContent.EMPTY_PARAMS); + assertThat(Strings.toString(builder), + equalTo("{" + + "\"invalidated_api_keys\":[\"api-key-id-1\"]," + + "\"previously_invalidated_api_keys\":[\"api-key-id-2\",\"api-key-id-3\"]," + + "\"error_count\":2," + + "\"error_details\":[" + + "{\"type\":\"exception\"," + + "\"reason\":\"error1\"," + + "\"caused_by\":{" + + "\"type\":\"illegal_argument_exception\"," + + "\"reason\":\"msg - 1\"}" + + "}," + + "{\"type\":\"exception\"," + + "\"reason\":\"error2\"," + + "\"caused_by\":" + + "{\"type\":\"illegal_argument_exception\"," + + "\"reason\":\"msg - 2\"}" + + "}" + + "]" + + "}")); + } +} 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 82a030f0b1474..611ce7cf09db0 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 @@ -85,6 +85,7 @@ import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction; @@ -138,6 +139,7 @@ import org.elasticsearch.xpack.core.ssl.rest.RestGetCertificateInfoAction; import org.elasticsearch.xpack.core.template.TemplateUtils; import org.elasticsearch.xpack.security.action.TransportCreateApiKeyAction; +import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.interceptor.BulkShardRequestInterceptor; import org.elasticsearch.xpack.security.action.interceptor.IndicesAliasesRequestInterceptor; @@ -752,7 +754,8 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(GetPrivilegesAction.INSTANCE, TransportGetPrivilegesAction.class), new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), - new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class) + new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class), + new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java new file mode 100644 index 0000000000000..80a74037657e1 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +public final class TransportInvalidateApiKeyAction extends HandledTransportAction { + + private final ApiKeyService apiKeyService; + + @Inject + public TransportInvalidateApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService) { + super(InvalidateApiKeyAction.NAME, transportService, actionFilters, + (Writeable.Reader) InvalidateApiKeyRequest::new); + this.apiKeyService = apiKeyService; + } + + @Override + protected void doExecute(Task task, InvalidateApiKeyRequest request, ActionListener listener) { + if (Strings.hasText(request.getRealmName()) || Strings.hasText(request.getUserName())) { + apiKeyService.invalidateApiKeysForRealmAndUser(request.getRealmName(), request.getUserName(), listener); + } else if (Strings.hasText(request.getApiKeyId())) { + apiKeyService.invalidateApiKeyForApiKeyId(request.getApiKeyId(), listener); + } else { + apiKeyService.invalidateApiKeyForApiKeyName(request.getApiKeyName(), listener); + } + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index dd2c9aa22536a..9216fcfe894dc 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -8,15 +8,28 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.ExceptionsHelper; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.DocWriteResponse; +import org.elasticsearch.action.bulk.BulkItemResponse; +import org.elasticsearch.action.bulk.BulkRequestBuilder; +import org.elasticsearch.action.bulk.BulkResponse; import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexAction; import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.action.support.WriteRequest.RefreshPolicy; +import org.elasticsearch.action.update.UpdateRequest; +import org.elasticsearch.action.update.UpdateResponse; import org.elasticsearch.client.Client; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.bytes.BytesReference; @@ -31,9 +44,14 @@ import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.search.SearchHit; import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.ScrollHelper; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.support.Hasher; @@ -43,15 +61,17 @@ import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.support.SecurityIndexManager; -import javax.crypto.SecretKeyFactory; import java.io.Closeable; import java.io.IOException; import java.io.UncheckedIOException; import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -60,6 +80,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +import javax.crypto.SecretKeyFactory; + +import static org.elasticsearch.search.SearchService.DEFAULT_KEEPALIVE_SETTING; import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; @@ -94,6 +117,7 @@ public class ApiKeyService { private final ClusterService clusterService; private final Hasher hasher; private final boolean enabled; + private final Settings settings; public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex, ClusterService clusterService) { this.clock = clock; @@ -102,6 +126,7 @@ public ApiKeyService(Settings settings, Clock clock, Client client, SecurityInde this.clusterService = clusterService; this.enabled = XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.get(settings); this.hasher = Hasher.resolve(PASSWORD_HASHING_ALGORITHM.get(settings)); + this.settings = settings; } /** @@ -115,61 +140,78 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ if (authentication == null) { listener.onFailure(new IllegalArgumentException("authentication must be provided")); } else { - final Instant created = clock.instant(); - final Instant expiration = getApiKeyExpiration(created, request); - final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); - final Version version = clusterService.state().nodes().getMinNodeVersion(); - if (version.before(Version.V_7_0_0)) { // TODO(jaymode) change to V6_6_0 on backport! - logger.warn("nodes prior to the minimum supported version for api keys {} exist in the cluster; these nodes will not be " + - "able to use api keys", Version.V_7_0_0); - } - - final char[] keyHash = hasher.hash(apiKey); - try (XContentBuilder builder = XContentFactory.jsonBuilder()) { - builder.startObject() - .field("doc_type", "api_key") - .field("creation_time", created.toEpochMilli()) - .field("expiration_time", expiration == null ? null : expiration.toEpochMilli()); - - byte[] utf8Bytes = null; - try { - utf8Bytes = CharArrays.toUtf8Bytes(keyHash); - builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length); - } finally { - if (utf8Bytes != null) { - Arrays.fill(utf8Bytes, (byte) 0); + /* + * 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. + */ + findActiveApiKeyForApiKeyName(request.getName(), ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + final Instant created = clock.instant(); + final Instant expiration = getApiKeyExpiration(created, request); + final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); + final Version version = clusterService.state().nodes().getMinNodeVersion(); + if (version.before(Version.V_7_0_0)) { // TODO(jaymode) change to V6_6_0 on backport! + logger.warn( + "nodes prior to the minimum supported version for api keys {} exist in the cluster;" + + " these nodes will not be able to use api keys", + Version.V_7_0_0); } - } - builder.startObject("role_descriptors"); - for (RoleDescriptor descriptor : request.getRoleDescriptors()) { - builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + final char[] keyHash = hasher.hash(apiKey); + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject() + .field("doc_type", "api_key") + .field("creation_time", created.toEpochMilli()) + .field("expiration_time", expiration == null ? null : expiration.toEpochMilli()) + .field("api_key_invalidated", false); + + byte[] utf8Bytes = null; + try { + utf8Bytes = CharArrays.toUtf8Bytes(keyHash); + builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length); + } finally { + if (utf8Bytes != null) { + Arrays.fill(utf8Bytes, (byte) 0); + } + } + + builder.startObject("role_descriptors"); + for (RoleDescriptor descriptor : request.getRoleDescriptors()) { + builder.field(descriptor.getName(), + (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + } + builder.endObject(); + builder.field("name", request.getName()) + .field("version", version.id) + .startObject("creator") + .field("principal", authentication.getUser().principal()) + .field("metadata", authentication.getUser().metadata()) + .field("realm", authentication.getLookedUpBy() == null ? + authentication.getAuthenticatedBy().getName() : authentication.getLookedUpBy().getName()) + .endObject() + .endObject(); + final IndexRequest indexRequest = + client.prepareIndex(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE) + .setSource(builder) + .setRefreshPolicy(request.getRefreshPolicy()) + .request(); + securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () -> + executeAsyncWithOrigin(client, SECURITY_ORIGIN, IndexAction.INSTANCE, indexRequest, + ActionListener.wrap( + indexResponse -> listener.onResponse( + new CreateApiKeyResponse(request.getName(), indexResponse.getId(), apiKey, expiration)), + listener::onFailure))); + } catch (IOException e) { + listener.onFailure(e); + } finally { + Arrays.fill(keyHash, (char) 0); + } + } else { + listener.onFailure(traceLog("create api key", new ElasticsearchSecurityException( + "Error creating api key as api key with name [{}] already exists", request.getName()))); } - builder.endObject(); - builder.field("name", request.getName()) - .field("version", version.id) - .startObject("creator") - .field("principal", authentication.getUser().principal()) - .field("metadata", authentication.getUser().metadata()) - .field("realm", authentication.getLookedUpBy() == null ? - authentication.getAuthenticatedBy().getName() : authentication.getLookedUpBy().getName()) - .endObject() - .endObject(); - final IndexRequest indexRequest = - client.prepareIndex(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE) - .setSource(builder) - .setRefreshPolicy(request.getRefreshPolicy()) - .request(); - securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () -> - executeAsyncWithOrigin(client, SECURITY_ORIGIN, IndexAction.INSTANCE, indexRequest, - ActionListener.wrap(indexResponse -> - listener.onResponse(new CreateApiKeyResponse(request.getName(), indexResponse.getId(), apiKey, expiration)), - listener::onFailure))); - } catch (IOException e) { - listener.onFailure(e); - } finally { - Arrays.fill(keyHash, (char) 0); - } + }, listener::onFailure)); } } @@ -256,30 +298,37 @@ public void getRoleForApiKey(Authentication authentication, CompositeRolesStore */ static void validateApiKeyCredentials(Map source, ApiKeyCredentials credentials, Clock clock, ActionListener listener) { - final String apiKeyHash = (String) source.get("api_key_hash"); - if (apiKeyHash == null) { - throw new IllegalStateException("api key hash is missing"); - } - final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials); - - if (verified) { - final Long expirationEpochMilli = (Long) source.get("expiration_time"); - if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) { - final Map creator = Objects.requireNonNull((Map) source.get("creator")); - final String principal = Objects.requireNonNull((String) creator.get("principal")); - final Map metadata = (Map) creator.get("metadata"); - final Map roleDescriptors = (Map) source.get("role_descriptors"); - final String[] roleNames = roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY); - final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); - final Map authResultMetadata = new HashMap<>(); - authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors); - authResultMetadata.put(API_KEY_ID_KEY, credentials.getId()); - listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata)); + final Boolean invalidated = (Boolean) source.get("api_key_invalidated"); + if (invalidated == null) { + listener.onResponse(AuthenticationResult.terminate("api key document is missing invalidated field", null)); + } else if (invalidated) { + listener.onResponse(AuthenticationResult.terminate("api key has been invalidated", null)); + } else { + final String apiKeyHash = (String) source.get("api_key_hash"); + if (apiKeyHash == null) { + throw new IllegalStateException("api key hash is missing"); + } + final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials); + + if (verified) { + final Long expirationEpochMilli = (Long) source.get("expiration_time"); + if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) { + final Map creator = Objects.requireNonNull((Map) source.get("creator")); + final String principal = Objects.requireNonNull((String) creator.get("principal")); + final Map metadata = (Map) creator.get("metadata"); + final Map roleDescriptors = (Map) source.get("role_descriptors"); + final String[] roleNames = roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY); + final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); + final Map authResultMetadata = new HashMap<>(); + authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors); + authResultMetadata.put(API_KEY_ID_KEY, credentials.getId()); + listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata)); + } else { + listener.onResponse(AuthenticationResult.terminate("api key is expired", null)); + } } else { - listener.onResponse(AuthenticationResult.terminate("api key is expired", null)); + listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null)); } - } else { - listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null)); } } @@ -387,4 +436,218 @@ public void usedDeprecatedField(String usedName, String replacedWith) { usedName, apiKeyId, replacedWith); } } + + /** + * Invalidate API keys for given realm and user name. + * @param realmName realm name + * @param userName user name + * @param invalidateListener listener for {@link InvalidateApiKeyResponse} + */ + public void invalidateApiKeysForRealmAndUser(String realmName, String userName, + ActionListener invalidateListener) { + ensureEnabled(); + if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false) { + logger.trace("No realm name or username provided"); + invalidateListener.onFailure(new IllegalArgumentException("realm name or username must be provided")); + } else { + findActiveApiKeysForUserAndRealm(userName, realmName, ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + logger.warn("No active api keys to invalidate for realm [{}] and username [{}]", realmName, userName); + invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); + } else { + invalidateAllApiKeys(apiKeyIds, invalidateListener); + } + }, invalidateListener::onFailure)); + } + } + + private void invalidateAllApiKeys(Collection apiKeyIds, ActionListener invalidateListener) { + indexInvalidation(apiKeyIds, invalidateListener, null); + } + + /** + * Invalidate API key for given API key id + * @param apiKeyId API key id + * @param invalidateListener listener for {@link InvalidateApiKeyResponse} + */ + public void invalidateApiKeyForApiKeyId(String apiKeyId, ActionListener invalidateListener) { + ensureEnabled(); + invalidateAllApiKeys(Collections.singleton(apiKeyId), invalidateListener); + } + + /** + * Invalidate API key for given API key name + * @param apiKeyName API key name + * @param invalidateListener listener for {@link InvalidateApiKeyResponse} + */ + public void invalidateApiKeyForApiKeyName(String apiKeyName, ActionListener invalidateListener) { + ensureEnabled(); + if (Strings.hasText(apiKeyName) == false) { + logger.trace("No api key name provided"); + invalidateListener.onFailure(new IllegalArgumentException("api key name must be provided")); + } else { + findActiveApiKeyForApiKeyName(apiKeyName, ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + logger.warn("No api key to invalidate for api key name [{}]", apiKeyName); + invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); + } else { + invalidateAllApiKeys(apiKeyIds, invalidateListener); + } + }, invalidateListener::onFailure)); + } + } + + private void findActiveApiKeysForUserAndRealm(String userName, String realmName, ActionListener> listener) { + final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); + if (frozenSecurityIndex.indexExists() == false) { + listener.onResponse(Collections.emptyList()); + } else if (frozenSecurityIndex.isAvailable() == false) { + listener.onFailure(frozenSecurityIndex.getUnavailableReason()); + } else { + final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("doc_type", "api_key")) + .filter(QueryBuilders.termQuery("api_key_invalidated", false)); + if (Strings.hasText(userName)) { + boolQuery.filter(QueryBuilders.termQuery("creator.principal", userName)); + } + if (Strings.hasText(realmName)) { + boolQuery.filter(QueryBuilders.termQuery("creator.realm", realmName)); + } + + findActiveApiKeys(boolQuery, listener); + } + } + + private void findActiveApiKeys(final BoolQueryBuilder boolQuery, ActionListener> listener) { + final SearchRequest request = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setScroll(DEFAULT_KEEPALIVE_SETTING.get(settings)) + .setQuery(boolQuery) + .setVersion(false) + .setSize(1000) + .setFetchSource(true) + .request(); + securityIndex.checkIndexVersionThenExecute(listener::onFailure, + () -> ScrollHelper.fetchAllByEntity(client, request, listener, + (SearchHit hit) -> hit.getId())); + } + + private void findActiveApiKeyForApiKeyName(String apiKeyName, ActionListener> listener) { + final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); + if (frozenSecurityIndex.indexExists() == false) { + listener.onResponse(Collections.emptyList()); + } else if (frozenSecurityIndex.isAvailable() == false) { + listener.onFailure(frozenSecurityIndex.getUnavailableReason()); + } else { + final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("doc_type", "api_key")) + .filter(QueryBuilders.termQuery("api_key_invalidated", false)); + if (Strings.hasText(apiKeyName)) { + boolQuery.filter(QueryBuilders.termQuery("name", apiKeyName)); + } + + findActiveApiKeys(boolQuery, listener); + } + } + + /** + * Performs the actual invalidation of a collection of api keys + * + * @param apiKeyIds the api keys to invalidate + * @param listener the listener to notify upon completion + * @param previousResult if this not the initial attempt for invalidation, it contains the result of invalidating + * api keys up to the point of the retry. This result is added to the result of the current attempt + */ + private void indexInvalidation(Collection apiKeyIds, ActionListener listener, + @Nullable InvalidateApiKeyResponse previousResult) { + if (apiKeyIds.isEmpty()) { + listener.onFailure(new ElasticsearchSecurityException("No api key ids provided for invalidation")); + } else { + BulkRequestBuilder bulkRequestBuilder = client.prepareBulk(); + for (String apiKeyId : apiKeyIds) { + UpdateRequest request = client.prepareUpdate(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE, apiKeyId) + .setDoc(Collections.singletonMap("api_key_invalidated", true)) + .request(); + bulkRequestBuilder.add(request); + } + bulkRequestBuilder.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL); + securityIndex.prepareIndexIfNeededThenExecute(ex -> listener.onFailure(traceLog("prepare security index", ex)), + () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, bulkRequestBuilder.request(), + ActionListener.wrap(bulkResponse -> { + ArrayList failedRequestResponses = new ArrayList<>(); + ArrayList previouslyInvalidated = new ArrayList<>(); + ArrayList invalidated = new ArrayList<>(); + if (null != previousResult) { + failedRequestResponses.addAll((previousResult.getErrors())); + previouslyInvalidated.addAll(previousResult.getPreviouslyInvalidatedApiKeys()); + invalidated.addAll(previousResult.getInvalidatedApiKeys()); + } + for (BulkItemResponse bulkItemResponse : bulkResponse.getItems()) { + if (bulkItemResponse.isFailed()) { + Throwable cause = bulkItemResponse.getFailure().getCause(); + final String failedApiKeyId = bulkItemResponse.getFailure().getId(); + traceLog("invalidate api key", failedApiKeyId, cause); + failedRequestResponses.add(new ElasticsearchException("Error invalidating api key", cause)); + } else { + UpdateResponse updateResponse = bulkItemResponse.getResponse(); + if (updateResponse.getResult() == DocWriteResponse.Result.UPDATED) { + logger.debug("Invalidated api key for doc [{}]", updateResponse.getId()); + invalidated.add(updateResponse.getId()); + } else if (updateResponse.getResult() == DocWriteResponse.Result.NOOP) { + previouslyInvalidated.add(updateResponse.getId()); + } + } + } + InvalidateApiKeyResponse result = new InvalidateApiKeyResponse(invalidated, previouslyInvalidated, + failedRequestResponses); + listener.onResponse(result); + }, e -> { + Throwable cause = ExceptionsHelper.unwrapCause(e); + traceLog("invalidate api keys", cause); + listener.onFailure(e); + }), client::bulk)); + } + } + + /** + * Logs an exception concerning a specific api key at TRACE level (if enabled) + */ + private E traceLog(String action, String identifier, E exception) { + if (logger.isTraceEnabled()) { + if (exception instanceof ElasticsearchException) { + final ElasticsearchException esEx = (ElasticsearchException) exception; + final Object detail = esEx.getHeader("error_description"); + if (detail != null) { + logger.trace(() -> new ParameterizedMessage("Failure in [{}] for id [{}] - [{}]", action, identifier, detail), + esEx); + } else { + logger.trace(() -> new ParameterizedMessage("Failure in [{}] for id [{}]", action, identifier), + esEx); + } + } else { + logger.trace(() -> new ParameterizedMessage("Failure in [{}] for id [{}]", action, identifier), exception); + } + } + return exception; + } + + /** + * Logs an exception at TRACE level (if enabled) + */ + private E traceLog(String action, E exception) { + if (logger.isTraceEnabled()) { + if (exception instanceof ElasticsearchException) { + final ElasticsearchException esEx = (ElasticsearchException) exception; + final Object detail = esEx.getHeader("error_description"); + if (detail != null) { + logger.trace(() -> new ParameterizedMessage("Failure in [{}] - [{}]", action, detail), esEx); + } else { + logger.trace(() -> new ParameterizedMessage("Failure in [{}]", action), esEx); + } + } else { + logger.trace(() -> new ParameterizedMessage("Failure in [{}]", action), exception); + } + } + return exception; + } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 59200d2739ea2..c6be2365ad075 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -8,6 +8,7 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; +import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; @@ -17,6 +18,8 @@ import org.elasticsearch.test.SecuritySettingsSourceField; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.client.SecurityClient; @@ -27,11 +30,16 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @@ -104,4 +112,125 @@ public void testCreateApiKey() { assertThat(e.getMessage(), containsString("unauthorized")); assertThat(e.status(), is(RestStatus.FORBIDDEN)); } + + public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() { + String keyName = randomAlphaOfLength(5); + { + 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))); + SecurityClient securityClient = new SecurityClient(client); + final CreateApiKeyResponse response = securityClient.prepareCreateApiKey().setName(keyName).setExpiration(null) + .setRoleDescriptors(Collections.singletonList(descriptor)).get(); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + } + + 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))); + SecurityClient securityClient = new SecurityClient(client); + ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> securityClient.prepareCreateApiKey() + .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")); + } + + public void testInvalidateApiKeysForRealm() throws InterruptedException, ExecutionException { + int noOfApiKeys = randomIntBetween(3, 5); + List responses = createApiKeys(noOfApiKeys, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingRealmName("file"), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(noOfApiKeys)); + assertThat(invalidateResponse.getInvalidatedApiKeys(), + equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + public void testInvalidateApiKeysForUser() throws Exception { + int noOfApiKeys = randomIntBetween(3, 5); + List responses = createApiKeys(noOfApiKeys, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingUserName(SecuritySettingsSource.TEST_SUPERUSER), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(noOfApiKeys)); + assertThat(invalidateResponse.getInvalidatedApiKeys(), + equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + public void testInvalidateApiKeysForRealmAndUser() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingRealmAndUserName("file", SecuritySettingsSource.TEST_SUPERUSER), + listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); + assertThat(invalidateResponse.getInvalidatedApiKeys(), + equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + public void testInvalidateApiKeysForApiKeyId() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); + assertThat(invalidateResponse.getInvalidatedApiKeys(), + equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + public void testInvalidateApiKeysForApiKeyName() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyName(responses.get(0).getName()), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); + assertThat(invalidateResponse.getInvalidatedApiKeys(), + equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + private List createApiKeys(int noOfApiKeys, TimeValue expiration) { + List responses = new ArrayList<>(); + 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))); + SecurityClient securityClient = new SecurityClient(client); + final CreateApiKeyResponse response = securityClient.prepareCreateApiKey() + .setName("test-key-" + randomAlphaOfLengthBetween(5, 9) + i).setExpiration(expiration) + .setRoleDescriptors(Collections.singletonList(descriptor)).get(); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + responses.add(response); + } + return responses; + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index cee5711780f11..40b24bbc93881 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -44,11 +44,11 @@ import java.util.HashMap; import java.util.Map; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.is; import static org.mockito.Matchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; -import static org.hamcrest.Matchers.arrayContaining; -import static org.hamcrest.Matchers.is; public class ApiKeyServiceTests extends ESTestCase { @@ -111,6 +111,7 @@ public void testValidateApiKey() throws Exception { creatorMap.put("principal", "test_user"); creatorMap.put("metadata", Collections.emptyMap()); sourceMap.put("creator", creatorMap); + sourceMap.put("api_key_invalidated", false); ApiKeyService.ApiKeyCredentials creds = new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); @@ -147,6 +148,14 @@ public void testValidateApiKey() throws Exception { result = future.get(); assertNotNull(result); assertFalse(result.isAuthenticated()); + + sourceMap.put("api_key_invalidated", true); + creds = new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(randomAlphaOfLength(15).toCharArray())); + future = new PlainActionFuture<>(); + ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); + result = future.get(); + assertNotNull(result); + assertFalse(result.isAuthenticated()); } public void testGetRolesForApiKeyNotInContext() throws Exception { @@ -165,7 +174,6 @@ public void testGetRolesForApiKeyNotInContext() throws Exception { final Authentication authentication = new Authentication(new User("joe"), new RealmRef("apikey", "apikey", "node"), null, Version.CURRENT, AuthenticationType.API_KEY, authMetadata); - final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); ApiKeyService service = new ApiKeyService(Settings.EMPTY, Clock.systemUTC(), null, null, ClusterServiceUtils.createClusterService(threadPool)); CompositeRolesStore rolesStore = mock(CompositeRolesStore.class); @@ -187,4 +195,5 @@ public void testGetRolesForApiKeyNotInContext() throws Exception { Role role = roleFuture.get(); assertThat(role.names(), arrayContaining("superuser")); } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index daa707b7c88db..afd81104f8ab8 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -1221,6 +1221,7 @@ public void testApiKeyAuth() { final Map source = new HashMap<>(); source.put("doc_type", "api_key"); source.put("creation_time", Instant.now().minus(5, ChronoUnit.MINUTES).toEpochMilli()); + source.put("api_key_invalidated", false); source.put("api_key_hash", new String(Hasher.BCRYPT4.hash(new SecureString(key.toCharArray())))); source.put("role_descriptors", Collections.singletonMap("api key role", Collections.singletonMap("cluster", "all"))); source.put("name", "my api key for testApiKeyAuth"); @@ -1259,6 +1260,7 @@ public void testExpiredApiKey() { source.put("doc_type", "api_key"); source.put("creation_time", Instant.now().minus(5L, ChronoUnit.HOURS).toEpochMilli()); source.put("expiration_time", Instant.now().minus(1L, ChronoUnit.HOURS).toEpochMilli()); + source.put("api_key_invalidated", false); source.put("api_key_hash", new String(Hasher.BCRYPT4.hash(new SecureString(key.toCharArray())))); source.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); source.put("name", "my api key for testApiKeyAuth"); From fabcbb81b660af5a71d7cf654cee21d4d2de39ae Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Fri, 18 Jan 2019 11:55:07 +1100 Subject: [PATCH 14/26] HLRC - Add support for create API key (#37389) This commit adds support for API to create an API key in high-level rest client. --- client/rest-high-level/build.gradle | 1 + .../elasticsearch/client/SecurityClient.java | 32 +++++ .../client/SecurityRequestConverters.java | 9 ++ .../client/security/CreateApiKeyRequest.java | 128 ++++++++++++++++++ .../client/security/CreateApiKeyResponse.java | 105 ++++++++++++++ .../SecurityRequestConvertersTests.java | 28 +++- .../SecurityDocumentationIT.java | 75 +++++++++- .../security/CreateApiKeyRequestTests.java | 103 ++++++++++++++ .../security/CreateApiKeyResponseTests.java | 101 ++++++++++++++ .../security/create-api-key.asciidoc | 40 ++++++ .../high-level/supported-apis.asciidoc | 2 + 11 files changed, 619 insertions(+), 5 deletions(-) create mode 100644 client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java create mode 100644 client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyResponse.java create mode 100644 client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java create mode 100644 client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyResponseTests.java create mode 100644 docs/java-rest/high-level/security/create-api-key.asciidoc diff --git a/client/rest-high-level/build.gradle b/client/rest-high-level/build.gradle index b71ca82c7d094..4c85f00ee59a6 100644 --- a/client/rest-high-level/build.gradle +++ b/client/rest-high-level/build.gradle @@ -104,6 +104,7 @@ integTestCluster { setting 'xpack.license.self_generated.type', 'trial' setting 'xpack.security.enabled', 'true' setting 'xpack.security.authc.token.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' // Truststore settings are not used since TLS is not enabled. Included for testing the get certificates API setting 'xpack.security.http.ssl.certificate_authorities', 'testnode.crt' setting 'xpack.security.transport.ssl.truststore.path', 'testnode.jks' diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java index 48a1cdb778243..5593c0257e06a 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java @@ -27,6 +27,8 @@ import org.elasticsearch.client.security.ClearRealmCacheResponse; import org.elasticsearch.client.security.ClearRolesCacheRequest; import org.elasticsearch.client.security.ClearRolesCacheResponse; +import org.elasticsearch.client.security.CreateApiKeyRequest; +import org.elasticsearch.client.security.CreateApiKeyResponse; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.CreateTokenResponse; import org.elasticsearch.client.security.DeletePrivilegesRequest; @@ -747,4 +749,34 @@ public void deletePrivilegesAsync(DeletePrivilegesRequest request, RequestOption restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::deletePrivileges, options, DeletePrivilegesResponse::fromXContent, listener, singleton(404)); } + + /** + * Create an API Key.
+ * See + * the docs for more. + * + * @param request the request to create api key + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @return the response from the create api key call + * @throws IOException in case there is a problem sending the request or parsing back the response + */ + public CreateApiKeyResponse createApiKey(final CreateApiKeyRequest request, final RequestOptions options) throws IOException { + return restHighLevelClient.performRequestAndParseEntity(request, SecurityRequestConverters::createApiKey, options, + CreateApiKeyResponse::fromXContent, emptySet()); + } + + /** + * Asynchronously creates an API key.
+ * See + * the docs for more. + * + * @param request the request to create api key + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @param listener the listener to be notified upon request completion + */ + public void createApiKeyAsync(final CreateApiKeyRequest request, final RequestOptions options, + final ActionListener listener) { + restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::createApiKey, options, + CreateApiKeyResponse::fromXContent, listener, emptySet()); + } } diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java index 9e9698ded1cd8..098750c2bc395 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java @@ -26,6 +26,7 @@ import org.elasticsearch.client.security.ChangePasswordRequest; import org.elasticsearch.client.security.ClearRealmCacheRequest; import org.elasticsearch.client.security.ClearRolesCacheRequest; +import org.elasticsearch.client.security.CreateApiKeyRequest; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.DeletePrivilegesRequest; import org.elasticsearch.client.security.DeleteRoleMappingRequest; @@ -256,4 +257,12 @@ static Request putRole(final PutRoleRequest putRoleRequest) throws IOException { params.withRefreshPolicy(putRoleRequest.getRefreshPolicy()); return request; } + + static Request createApiKey(final CreateApiKeyRequest createApiKeyRequest) throws IOException { + final Request request = new Request(HttpPost.METHOD_NAME, "/_security/api_key"); + request.setEntity(createEntity(createApiKeyRequest, REQUEST_BODY_CONTENT_TYPE)); + final RequestConverters.Params params = new RequestConverters.Params(request); + params.withRefreshPolicy(createApiKeyRequest.getRefreshPolicy()); + return request; + } } diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java new file mode 100644 index 0000000000000..ad5f0a9ba2cf6 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java @@ -0,0 +1,128 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +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; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** + * Request to create API key + */ +public final class CreateApiKeyRequest implements Validatable, ToXContentObject { + + private final String name; + private final TimeValue expiration; + private final List roles; + private final RefreshPolicy refreshPolicy; + + /** + * Create API Key request constructor + * @param name name for the API key + * @param roles list of {@link Role}s + * @param expiration to specify expiration for the API key + */ + public CreateApiKeyRequest(String name, List 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"); + } + this.roles = Objects.requireNonNull(roles, "roles may not be null"); + this.expiration = expiration; + this.refreshPolicy = (refreshPolicy == null) ? RefreshPolicy.getDefault() : refreshPolicy; + } + + public String getName() { + return name; + } + + public TimeValue getExpiration() { + return expiration; + } + + public List getRoles() { + return roles; + } + + public RefreshPolicy getRefreshPolicy() { + return refreshPolicy; + } + + @Override + public int hashCode() { + return Objects.hash(name, refreshPolicy, roles, expiration); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final CreateApiKeyRequest that = (CreateApiKeyRequest) o; + return Objects.equals(name, that.name) && Objects.equals(refreshPolicy, that.refreshPolicy) && Objects.equals(roles, that.roles) + && Objects.equals(expiration, that.expiration); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject().field("name", name); + if (expiration != null) { + builder.field("expiration", expiration.getStringRep()); + } + builder.startObject("role_descriptors"); + for (Role role : roles) { + builder.startObject(role.getName()); + if (role.getApplicationPrivileges() != null) { + builder.field(Role.APPLICATIONS.getPreferredName(), role.getApplicationPrivileges()); + } + if (role.getClusterPrivileges() != null) { + builder.field(Role.CLUSTER.getPreferredName(), role.getClusterPrivileges()); + } + if (role.getGlobalPrivileges() != null) { + builder.field(Role.GLOBAL.getPreferredName(), role.getGlobalPrivileges()); + } + if (role.getIndicesPrivileges() != null) { + builder.field(Role.INDICES.getPreferredName(), role.getIndicesPrivileges()); + } + if (role.getMetadata() != null) { + builder.field(Role.METADATA.getPreferredName(), role.getMetadata()); + } + if (role.getRunAsPrivilege() != null) { + builder.field(Role.RUN_AS.getPreferredName(), role.getRunAsPrivilege()); + } + builder.endObject(); + } + builder.endObject(); + return builder.endObject(); + } + +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyResponse.java new file mode 100644 index 0000000000000..9c5037237407b --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyResponse.java @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentParser; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Response for create API key + */ +public final class CreateApiKeyResponse { + + private final String name; + private final String id; + private final SecureString key; + private final Instant expiration; + + public CreateApiKeyResponse(String name, String id, SecureString key, Instant expiration) { + this.name = name; + this.id = id; + this.key = key; + // As we do not yet support the nanosecond precision when we serialize to JSON, + // here creating the 'Instant' of milliseconds precision. + // This Instant can then be used for date comparison. + this.expiration = (expiration != null) ? Instant.ofEpochMilli(expiration.toEpochMilli()): null; + } + + public String getName() { + return name; + } + + public String getId() { + return id; + } + + public SecureString getKey() { + return key; + } + + @Nullable + public Instant getExpiration() { + return expiration; + } + + @Override + public int hashCode() { + return Objects.hash(id, name, key, expiration); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final CreateApiKeyResponse other = (CreateApiKeyResponse) obj; + return Objects.equals(id, other.id) + && Objects.equals(key, other.key) + && Objects.equals(name, other.name) + && Objects.equals(expiration, other.expiration); + } + + static ConstructingObjectParser PARSER = new ConstructingObjectParser<>("create_api_key_response", + args -> new CreateApiKeyResponse((String) args[0], (String) args[1], new SecureString((String) args[2]), + (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]))); + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareString(constructorArg(), new ParseField("id")); + PARSER.declareString(constructorArg(), new ParseField("api_key")); + PARSER.declareLong(optionalConstructorArg(), new ParseField("expiration")); + } + + public static CreateApiKeyResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } +} diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java index 900f4210a9952..88d18acd9a259 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java @@ -24,6 +24,7 @@ import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.elasticsearch.client.security.ChangePasswordRequest; +import org.elasticsearch.client.security.CreateApiKeyRequest; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.DeletePrivilegesRequest; import org.elasticsearch.client.security.DeleteRoleMappingRequest; @@ -44,11 +45,14 @@ import org.elasticsearch.client.security.support.expressiondsl.expressions.AnyRoleMapperExpression; import org.elasticsearch.client.security.support.expressiondsl.fields.FieldRoleMapperExpression; import org.elasticsearch.client.security.user.User; -import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges; import org.elasticsearch.client.security.user.privileges.ApplicationPrivilege; +import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges; import org.elasticsearch.client.security.user.privileges.IndicesPrivileges; import org.elasticsearch.client.security.user.privileges.Role; +import org.elasticsearch.client.security.user.privileges.Role.ClusterPrivilegeName; +import org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName; import org.elasticsearch.common.Strings; +import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.test.ESTestCase; @@ -410,4 +414,24 @@ public void testPutRole() throws IOException { assertEquals(expectedParams, request.getParameters()); assertToXContentBody(putRoleRequest, request.getEntity()); } -} + + public void testCreateApiKey() throws IOException { + final String name = randomAlphaOfLengthBetween(4, 7); + final List roles = Collections.singletonList(Role.builder().name("r1").clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); + final TimeValue expiration = randomBoolean() ? null : TimeValue.timeValueHours(24); + final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + final Map expectedParams; + if (refreshPolicy != RefreshPolicy.NONE) { + expectedParams = Collections.singletonMap("refresh", refreshPolicy.getValue()); + } else { + expectedParams = Collections.emptyMap(); + } + final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + final Request request = SecurityRequestConverters.createApiKey(createApiKeyRequest); + assertEquals(HttpPost.METHOD_NAME, request.getMethod()); + assertEquals("/_security/api_key", request.getEndpoint()); + assertEquals(expectedParams, request.getParameters()); + assertToXContentBody(createApiKeyRequest, request.getEntity()); + } + } 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 c8220e9cc0c05..87e798dc30750 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 @@ -33,6 +33,8 @@ import org.elasticsearch.client.security.ClearRealmCacheResponse; import org.elasticsearch.client.security.ClearRolesCacheRequest; import org.elasticsearch.client.security.ClearRolesCacheResponse; +import org.elasticsearch.client.security.CreateApiKeyRequest; +import org.elasticsearch.client.security.CreateApiKeyResponse; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.CreateTokenResponse; import org.elasticsearch.client.security.DeletePrivilegesRequest; @@ -79,14 +81,16 @@ import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges; import org.elasticsearch.client.security.user.privileges.IndicesPrivileges; import org.elasticsearch.client.security.user.privileges.Role; +import org.elasticsearch.client.security.user.privileges.Role.ClusterPrivilegeName; +import org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName; import org.elasticsearch.client.security.user.privileges.UserIndicesPrivileges; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.set.Sets; import org.hamcrest.Matchers; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; - import java.io.IOException; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -99,6 +103,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; @@ -1747,4 +1754,66 @@ public void onFailure(Exception e) { } } + public void testCreateApiKey() throws Exception { + RestHighLevelClient client = highLevelClient(); + + List roles = Collections.singletonList(Role.builder().name("r1").clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); + final TimeValue expiration = TimeValue.timeValueHours(24); + final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + { + final String name = randomAlphaOfLength(5); + // tag::create-api-key-request + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + // end::create-api-key-request + + // tag::create-api-key-execute + CreateApiKeyResponse createApiKeyResponse = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); + // end::create-api-key-execute + + // tag::create-api-key-response + SecureString apiKey = createApiKeyResponse.getKey(); // <1> + Instant apiKeyExpiration = createApiKeyResponse.getExpiration(); // <2> + // end::create-api-key-response + assertThat(createApiKeyResponse.getName(), equalTo(name)); + assertNotNull(apiKey); + assertNotNull(apiKeyExpiration); + } + + { + final String name = randomAlphaOfLength(5); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + + ActionListener listener; + // tag::create-api-key-execute-listener + listener = new ActionListener() { + @Override + public void onResponse(CreateApiKeyResponse createApiKeyResponse) { + // <1> + } + + @Override + public void onFailure(Exception e) { + // <2> + } + }; + // end::create-api-key-execute-listener + + // Avoid unused variable warning + assertNotNull(listener); + + // Replace the empty listener by a blocking listener in test + final PlainActionFuture future = new PlainActionFuture<>(); + listener = future; + + // tag::create-api-key-execute-async + client.security().createApiKeyAsync(createApiKeyRequest, RequestOptions.DEFAULT, listener); // <1> + // end::create-api-key-execute-async + + assertNotNull(future.get(30, TimeUnit.SECONDS)); + assertThat(future.get().getName(), equalTo(name)); + assertNotNull(future.get().getKey()); + assertNotNull(future.get().getExpiration()); + } + } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java new file mode 100644 index 0000000000000..5ac454a7534b0 --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.security.user.privileges.IndicesPrivileges; +import org.elasticsearch.client.security.user.privileges.Role; +import org.elasticsearch.client.security.user.privileges.Role.ClusterPrivilegeName; +import org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.EqualsHashCodeTestUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.equalTo; + +public class CreateApiKeyRequestTests extends ESTestCase { + + public void test() throws IOException { + List roles = new ArrayList<>(); + roles.add(Role.builder().name("r1").clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); + roles.add(Role.builder().name("r2").clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges(IndicesPrivileges.builder().indices("ind-y").privileges(IndexPrivilegeName.ALL).build()).build()); + + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("api-key", roles, null, null); + final XContentBuilder builder = XContentFactory.jsonBuilder(); + createApiKeyRequest.toXContent(builder, ToXContent.EMPTY_PARAMS); + final String output = Strings.toString(builder); + assertThat(output, equalTo( + "{\"name\":\"api-key\",\"role_descriptors\":{\"r1\":{\"applications\":[],\"cluster\":[\"all\"],\"indices\":[{\"names\":" + + "[\"ind-x\"],\"privileges\":[\"all\"]}],\"metadata\":{},\"run_as\":[]},\"r2\":{\"applications\":[],\"cluster\":" + + "[\"all\"],\"indices\":[{\"names\":[\"ind-y\"],\"privileges\":[\"all\"]}],\"metadata\":{},\"run_as\":[]}}}")); + } + + public void testEqualsHashCode() { + final String name = randomAlphaOfLength(5); + List roles = Collections.singletonList(Role.builder().name("r1").clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); + final TimeValue expiration = null; + final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyRequest, (original) -> { + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy()); + }); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyRequest, (original) -> { + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy()); + }, CreateApiKeyRequestTests::mutateTestItem); + } + + private static CreateApiKeyRequest mutateTestItem(CreateApiKeyRequest original) { + switch (randomIntBetween(0, 3)) { + case 0: + return new CreateApiKeyRequest(randomAlphaOfLength(5), original.getRoles(), original.getExpiration(), + original.getRefreshPolicy()); + case 1: + return new CreateApiKeyRequest(original.getName(), + Collections.singletonList(Role.builder().name(randomAlphaOfLength(6)).clusterPrivileges(ClusterPrivilegeName.ALL) + .indicesPrivileges( + IndicesPrivileges.builder().indices(randomAlphaOfLength(4)).privileges(IndexPrivilegeName.ALL).build()) + .build()), + original.getExpiration(), original.getRefreshPolicy()); + case 2: + return new CreateApiKeyRequest(original.getName(), original.getRoles(), TimeValue.timeValueSeconds(10000), + original.getRefreshPolicy()); + case 3: + List values = Arrays.stream(RefreshPolicy.values()).filter(rp -> rp != original.getRefreshPolicy()) + .collect(Collectors.toList()); + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), randomFrom(values)); + default: + return new CreateApiKeyRequest(randomAlphaOfLength(5), original.getRoles(), original.getExpiration(), + original.getRefreshPolicy()); + } + } +} diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyResponseTests.java new file mode 100644 index 0000000000000..4481d70c80b37 --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyResponseTests.java @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.EqualsHashCodeTestUtils; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; + +import static org.hamcrest.Matchers.equalTo; + +public class CreateApiKeyResponseTests extends ESTestCase { + + public void testFromXContent() throws IOException { + final String id = randomAlphaOfLengthBetween(4, 8); + final String name = randomAlphaOfLength(5); + final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); + final Instant expiration = randomBoolean() ? null : Instant.ofEpochMilli(10000); + + final XContentType xContentType = randomFrom(XContentType.values()); + final XContentBuilder builder = XContentFactory.contentBuilder(xContentType); + builder.startObject().field("id", id).field("name", name); + if (expiration != null) { + builder.field("expiration", expiration.toEpochMilli()); + } + byte[] charBytes = CharArrays.toUtf8Bytes(apiKey.getChars()); + try { + builder.field("api_key").utf8Value(charBytes, 0, charBytes.length); + } finally { + Arrays.fill(charBytes, (byte) 0); + } + builder.endObject(); + BytesReference xContent = BytesReference.bytes(builder); + + final CreateApiKeyResponse response = CreateApiKeyResponse.fromXContent(createParser(xContentType.xContent(), xContent)); + assertThat(response.getId(), equalTo(id)); + assertThat(response.getName(), equalTo(name)); + assertThat(response.getKey(), equalTo(apiKey)); + if (expiration != null) { + assertThat(response.getExpiration(), equalTo(expiration)); + } + } + + public void testEqualsHashCode() { + final String id = randomAlphaOfLengthBetween(4, 8); + final String name = randomAlphaOfLength(5); + final SecureString apiKey = UUIDs.randomBase64UUIDSecureString(); + final Instant expiration = Instant.ofEpochMilli(10000); + CreateApiKeyResponse createApiKeyResponse = new CreateApiKeyResponse(name, id, apiKey, expiration); + + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyResponse, (original) -> { + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), original.getExpiration()); + }); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyResponse, (original) -> { + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), original.getExpiration()); + }, CreateApiKeyResponseTests::mutateTestItem); + } + + private static CreateApiKeyResponse mutateTestItem(CreateApiKeyResponse original) { + switch (randomIntBetween(0, 3)) { + case 0: + return new CreateApiKeyResponse(randomAlphaOfLength(7), original.getId(), original.getKey(), original.getExpiration()); + case 1: + return new CreateApiKeyResponse(original.getName(), randomAlphaOfLengthBetween(4, 8), original.getKey(), + original.getExpiration()); + case 2: + return new CreateApiKeyResponse(original.getName(), original.getId(), UUIDs.randomBase64UUIDSecureString(), + original.getExpiration()); + case 3: + return new CreateApiKeyResponse(original.getName(), original.getId(), original.getKey(), Instant.ofEpochMilli(150000)); + default: + return new CreateApiKeyResponse(randomAlphaOfLength(7), original.getId(), original.getKey(), original.getExpiration()); + } + } +} diff --git a/docs/java-rest/high-level/security/create-api-key.asciidoc b/docs/java-rest/high-level/security/create-api-key.asciidoc new file mode 100644 index 0000000000000..93c3fa16de1da --- /dev/null +++ b/docs/java-rest/high-level/security/create-api-key.asciidoc @@ -0,0 +1,40 @@ +-- +:api: create-api-key +:request: CreateApiKeyRequest +:response: CreateApiKeyResponse +-- + +[id="{upid}-{api}"] +=== Create API Key API + +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 +optional expiration for the generated API key. +If expiration is not provided then by default the API +keys do not expire. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-request] +-------------------------------------------------- + +include::../execution.asciidoc[] + +[id="{upid}-{api}-response"] +==== Create API Key Response + +The returned +{response}+ contains an id, +API key, name for the API key and optional +expiration. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +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 \ No newline at end of file diff --git a/docs/java-rest/high-level/supported-apis.asciidoc b/docs/java-rest/high-level/supported-apis.asciidoc index 0b4a2570c896d..99a0d6725fda9 100644 --- a/docs/java-rest/high-level/supported-apis.asciidoc +++ b/docs/java-rest/high-level/supported-apis.asciidoc @@ -409,6 +409,7 @@ The Java High Level REST Client supports the following Security APIs: * <<{upid}-get-privileges>> * <<{upid}-put-privileges>> * <<{upid}-delete-privileges>> +* <<{upid}-create-api-key>> include::security/put-user.asciidoc[] include::security/get-users.asciidoc[] @@ -433,6 +434,7 @@ include::security/delete-role-mapping.asciidoc[] include::security/create-token.asciidoc[] include::security/invalidate-token.asciidoc[] include::security/put-privileges.asciidoc[] +include::security/create-api-key.asciidoc[] == Watcher APIs From 890c3a6545d5e38b924c94c0bce005137ecb9c09 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad Date: Tue, 22 Jan 2019 15:10:59 +1100 Subject: [PATCH 15/26] Fix test after merge from master for allow_restricted_indices --- .../client/security/CreateApiKeyRequestTests.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java index 5ac454a7534b0..188493deeb78a 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java @@ -55,8 +55,10 @@ public void test() throws IOException { final String output = Strings.toString(builder); assertThat(output, equalTo( "{\"name\":\"api-key\",\"role_descriptors\":{\"r1\":{\"applications\":[],\"cluster\":[\"all\"],\"indices\":[{\"names\":" - + "[\"ind-x\"],\"privileges\":[\"all\"]}],\"metadata\":{},\"run_as\":[]},\"r2\":{\"applications\":[],\"cluster\":" - + "[\"all\"],\"indices\":[{\"names\":[\"ind-y\"],\"privileges\":[\"all\"]}],\"metadata\":{},\"run_as\":[]}}}")); + + "[\"ind-x\"],\"privileges\":[\"all\"],\"allow_restricted_indices\":false}],\"metadata\":{},\"run_as\":[]}," + + "\"r2\":{\"applications\":[],\"cluster\":" + + "[\"all\"],\"indices\":[{\"names\":[\"ind-y\"],\"privileges\":[\"all\"],\"allow_restricted_indices\":false}]," + + "\"metadata\":{},\"run_as\":[]}}}")); } public void testEqualsHashCode() { From 75d27a9f516b56fae6d31fac6e17a2bc6cef96e9 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Wed, 23 Jan 2019 10:17:02 +1100 Subject: [PATCH 16/26] Add invalidated API keys remover (#37572) This commit adds support for deleting API keys. The API keys remover runs once a day and deletes invalidated or expired API keys after a retention period of one week. This is similar to ExpiredTokensRemover. --- .../xpack/security/Security.java | 2 + .../xpack/security/authc/ApiKeyService.java | 25 ++++ .../security/authc/ExpiredApiKeysRemover.java | 116 ++++++++++++++++++ .../security/authc/ApiKeyIntegTests.java | 110 ++++++++++++++++- 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredApiKeysRemover.java 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 4b1d72e4a4435..433fcce603c34 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 @@ -652,6 +652,8 @@ public static List> getSettings(boolean transportClientMode, List DELETE_TIMEOUT = Setting.timeSetting("xpack.security.authc.api_key.delete.timeout", + TimeValue.MINUS_ONE, Property.NodeScope); + public static final Setting DELETE_INTERVAL = Setting.timeSetting("xpack.security.authc.api_key.delete.interval", + TimeValue.timeValueHours(24L), Property.NodeScope); private final Clock clock; private final Client client; @@ -118,6 +124,10 @@ public class ApiKeyService { private final Hasher hasher; private final boolean enabled; private final Settings settings; + private final ExpiredApiKeysRemover expiredApiKeysRemover; + private final TimeValue deleteInterval; + + private volatile long lastExpirationRunMs; public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex, ClusterService clusterService) { this.clock = clock; @@ -127,6 +137,8 @@ public ApiKeyService(Settings settings, Clock clock, Client client, SecurityInde this.enabled = XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.get(settings); this.hasher = Hasher.resolve(PASSWORD_HASHING_ALGORITHM.get(settings)); this.settings = settings; + this.deleteInterval = DELETE_INTERVAL.get(settings); + this.expiredApiKeysRemover = new ExpiredApiKeysRemover(settings, client); } /** @@ -559,6 +571,7 @@ private void findActiveApiKeyForApiKeyName(String apiKeyName, ActionListener apiKeyIds, ActionListener listener, @Nullable InvalidateApiKeyResponse previousResult) { + maybeStartApiKeyRemover(); if (apiKeyIds.isEmpty()) { listener.onFailure(new ElasticsearchSecurityException("No api key ids provided for invalidation")); } else { @@ -650,4 +663,16 @@ private E traceLog(String action, E exception) { return exception; } + boolean isExpirationInProgress() { + return expiredApiKeysRemover.isExpirationInProgress(); + } + + private void maybeStartApiKeyRemover() { + if (securityIndex.isAvailable()) { + if (client.threadPool().relativeTimeInMillis() - lastExpirationRunMs > deleteInterval.getMillis()) { + expiredApiKeysRemover.submit(client.threadPool()); + lastExpirationRunMs = client.threadPool().relativeTimeInMillis(); + } + } + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredApiKeysRemover.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredApiKeysRemover.java new file mode 100644 index 0000000000000..b9430681d7f43 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredApiKeysRemover.java @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.bulk.BulkItemResponse; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.concurrent.AbstractRunnable; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.index.reindex.BulkByScrollResponse; +import org.elasticsearch.index.reindex.DeleteByQueryAction; +import org.elasticsearch.index.reindex.DeleteByQueryRequest; +import org.elasticsearch.index.reindex.ScrollableHitSource; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.threadpool.ThreadPool.Names; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.elasticsearch.action.support.TransportActions.isShardNotAvailableException; +import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; + +/** + * Responsible for cleaning the invalidated and expired API keys from the security index. + */ +public final class ExpiredApiKeysRemover extends AbstractRunnable { + private static final Logger logger = LogManager.getLogger(ExpiredApiKeysRemover.class); + + private final Client client; + private final AtomicBoolean inProgress = new AtomicBoolean(false); + private final TimeValue timeout; + + ExpiredApiKeysRemover(Settings settings, Client client) { + this.client = client; + this.timeout = ApiKeyService.DELETE_TIMEOUT.get(settings); + } + + @Override + public void doRun() { + DeleteByQueryRequest expiredDbq = new DeleteByQueryRequest(SecurityIndexManager.SECURITY_INDEX_NAME); + if (timeout != TimeValue.MINUS_ONE) { + expiredDbq.setTimeout(timeout); + expiredDbq.getSearchRequest().source().timeout(timeout); + } + final Instant now = Instant.now(); + expiredDbq + .setQuery(QueryBuilders.boolQuery() + .filter(QueryBuilders.termsQuery("doc_type", "api_key")) + .should(QueryBuilders.termsQuery("api_key_invalidated", true)) + .should(QueryBuilders.rangeQuery("expiration_time").lte(now.minus(7L, ChronoUnit.DAYS).toEpochMilli())) + .minimumShouldMatch(1) + ); + + logger.trace(() -> new ParameterizedMessage("Removing old api keys: [{}]", Strings.toString(expiredDbq))); + executeAsyncWithOrigin(client, SECURITY_ORIGIN, DeleteByQueryAction.INSTANCE, expiredDbq, + ActionListener.wrap(r -> { + debugDbqResponse(r); + markComplete(); + }, this::onFailure)); + } + + void submit(ThreadPool threadPool) { + if (inProgress.compareAndSet(false, true)) { + threadPool.executor(Names.GENERIC).submit(this); + } + } + + private void debugDbqResponse(BulkByScrollResponse response) { + if (logger.isDebugEnabled()) { + logger.debug("delete by query of api keys finished with [{}] deletions, [{}] bulk failures, [{}] search failures", + response.getDeleted(), response.getBulkFailures().size(), response.getSearchFailures().size()); + for (BulkItemResponse.Failure failure : response.getBulkFailures()) { + logger.debug(new ParameterizedMessage("deletion failed for index [{}], type [{}], id [{}]", + failure.getIndex(), failure.getType(), failure.getId()), failure.getCause()); + } + for (ScrollableHitSource.SearchFailure failure : response.getSearchFailures()) { + logger.debug(new ParameterizedMessage("search failed for index [{}], shard [{}] on node [{}]", + failure.getIndex(), failure.getShardId(), failure.getNodeId()), failure.getReason()); + } + } + } + + boolean isExpirationInProgress() { + return inProgress.get(); + } + + @Override + public void onFailure(Exception e) { + if (isShardNotAvailableException(e)) { + logger.debug("failed to delete expired or invalidated api keys", e); + } else { + logger.error("failed to delete expired or invalidated api keys", e); + } + markComplete(); + } + + private void markComplete() { + if (inProgress.compareAndSet(true, false) == false) { + throw new IllegalStateException("in progress was set to false but should have been true!"); + } + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index c6be2365ad075..25140fcd153ad 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -8,11 +8,15 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; +import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.search.builder.SearchSourceBuilder; import org.elasticsearch.test.SecurityIntegTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.test.SecuritySettingsSourceField; @@ -23,6 +27,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.client.SecurityClient; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.elasticsearch.xpack.security.transport.filter.IPFilter; import org.junit.After; import org.junit.Before; @@ -36,6 +41,8 @@ import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; @@ -50,6 +57,8 @@ public Settings nodeSettings(int nodeOrdinal) { return Settings.builder() .put(super.nodeSettings(nodeOrdinal)) .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) + .put(ApiKeyService.DELETE_INTERVAL.getKey(), TimeValue.timeValueMillis(200L)) + .put(ApiKeyService.DELETE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(5L)) .build(); } @@ -59,7 +68,12 @@ public void waitForSecurityIndexWritable() throws Exception { } @After - public void wipeSecurityIndex() { + public void wipeSecurityIndex() throws InterruptedException { + // get the api key service and wait until api key expiration is not in progress! + for (ApiKeyService apiKeyService : internalCluster().getInstances(ApiKeyService.class)) { + final boolean done = awaitBusy(() -> apiKeyService.isExpirationInProgress() == false); + assertTrue(done); + } deleteSecurityIndex(); } @@ -217,6 +231,100 @@ public void testInvalidateApiKeysForApiKeyName() throws InterruptedException, Ex assertThat(invalidateResponse.getErrors().size(), equalTo(0)); } + public void testInvalidatedApiKeysDeletedByRemover() throws Exception { + List responses = createApiKeys(1, null); + + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + AtomicReference docId = new AtomicReference<>(); + assertBusy(() -> { + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) + .setSize(1).setTerminateAfter(1).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + docId.set(searchResponse.getHits().getAt(0).getId()); + }); + + AtomicBoolean deleteTriggered = new AtomicBoolean(false); + assertBusy(() -> { + if (deleteTriggered.compareAndSet(false, true)) { + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + } + client.admin().indices().prepareRefresh(SecurityIndexManager.SECURITY_INDEX_NAME).get(); + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) + .setTerminateAfter(1).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L)); + }, 30, TimeUnit.SECONDS); + } + + public void testExpiredApiKeysDeletedAfter1Week() throws Exception { + createApiKeys(1, null); + Instant created = Instant.now(); + + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + + AtomicReference docId = new AtomicReference<>(); + assertBusy(() -> { + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))).setSize(1) + .setTerminateAfter(1).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + docId.set(searchResponse.getHits().getAt(0).getId()); + }); + + // hack doc to modify the expiration time to the week before + Instant weekBefore = created.minus(8L, ChronoUnit.DAYS); + assertTrue(Instant.now().isAfter(weekBefore)); + client.prepareUpdate(SecurityIndexManager.SECURITY_INDEX_NAME, "doc", docId.get()) + .setDoc("expiration_time", weekBefore.toEpochMilli()).setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE).get(); + + AtomicBoolean deleteTriggered = new AtomicBoolean(false); + assertBusy(() -> { + if (deleteTriggered.compareAndSet(false, true)) { + // just random api key invalidation so that it triggers expired keys remover + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(6)), new PlainActionFuture<>()); + } + client.admin().indices().prepareRefresh(SecurityIndexManager.SECURITY_INDEX_NAME).get(); + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) + .setTerminateAfter(1).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L)); + }, 30, TimeUnit.SECONDS); + } + + public void testActiveApiKeysWithNoExpirationNeverGetDeletedByRemover() throws Exception { + List responses = createApiKeys(1, null); + + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(7)), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(1)); + AtomicReference docId = new AtomicReference<>(); + assertBusy(() -> { + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) + .setSize(1).setTerminateAfter(1).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + docId.set(searchResponse.getHits().getAt(0).getId()); + }); + assertThat(docId.get(), equalTo(responses.get(0).getId())); + } + private List createApiKeys(int noOfApiKeys, TimeValue expiration) { List responses = new ArrayList<>(); for (int i = 0; i < noOfApiKeys; i++) { From fadbdfb511b9d3132a6c7e9cf65f58c8b36cfe53 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Wed, 23 Jan 2019 23:29:50 +1100 Subject: [PATCH 17/26] Invalidate API rest action (#37628) This commit adds invalidate API rest action. Also enhances create API key rest case to now verify login by API key. A new section type has been added to transform response values before stashing. The use case was for the creation of base64 encoded credentials for authenticating with API keys. --- .../rest-api-spec/test/README.asciidoc | 25 ++++ .../test/rest/yaml/Features.java | 3 +- .../rest/yaml/section/ExecutableSection.java | 1 + .../yaml/section/TransformAndSetSection.java | 106 +++++++++++++ .../section/TransformAndSetSectionTests.java | 96 ++++++++++++ x-pack/docs/en/rest-api/security.asciidoc | 4 +- .../security/invalidate-api-keys.asciidoc | 140 ++++++++++++++++++ .../action/InvalidateApiKeyRequest.java | 50 +++---- .../action/InvalidateApiKeyResponse.java | 23 +++ .../xpack/security/Security.java | 4 +- .../TransportInvalidateApiKeyAction.java | 6 +- .../action/RestInvalidateApiKeyAction.java | 71 +++++++++ .../RestInvalidateApiKeyActionTests.java | 124 ++++++++++++++++ .../api/security.invalidate_api_key.json | 15 ++ .../rest-api-spec/test/api_key/10_basic.yml | 89 ++++++++++- 15 files changed, 724 insertions(+), 33 deletions(-) create mode 100644 test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSection.java create mode 100644 test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSectionTests.java create mode 100644 x-pack/docs/en/rest-api/security/invalidate-api-keys.asciidoc create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyAction.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyActionTests.java create mode 100644 x-pack/plugin/src/test/resources/rest-api-spec/api/security.invalidate_api_key.json diff --git a/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc b/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc index a9b6639359e24..c83edb69b3e62 100644 --- a/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc +++ b/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc @@ -280,6 +280,31 @@ example above), but the same goes for actual values: The stash should be reset at the beginning of each test file. +=== `transform_and_set` + +For some tests, it is necessary to extract a value and transform it from the previous `response`, in +order to reuse it in a subsequent `do` and other tests. +Currently, it only has support for `base64EncodeCredentials`, for unknown transformations it will not +do anything and stash the value as is. +For instance, when testing you may want to base64 encode username and password for +`Basic` authorization header: + +.... + - do: + index: + index: test + type: test + - transform_and_set: { login_creds: "#base64EncodeCredentials(user,password)" } # stash the base64 encoded credentials of `response.user` and `response.password` as `login_creds` + - do: + headers: + Authorization: Basic ${login_creds} # replace `$login_creds` with the stashed value + get: + index: test + type: test +.... + +Stashed values can be used as described in the `set` section + === `is_true` The specified key exists and has a true value (ie not `0`, `false`, `undefined`, `null` diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/Features.java b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/Features.java index bb5354e4fedd3..fea1c3997530c 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/Features.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/Features.java @@ -46,7 +46,8 @@ public final class Features { "stash_path_replace", "warnings", "yaml", - "contains" + "contains", + "transform_and_set" )); private Features() { diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/ExecutableSection.java b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/ExecutableSection.java index ff02d6d16aa4a..135a60cca3431 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/ExecutableSection.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/ExecutableSection.java @@ -40,6 +40,7 @@ public interface ExecutableSection { List DEFAULT_EXECUTABLE_CONTEXTS = unmodifiableList(Arrays.asList( new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("do"), DoSection::parse), new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("set"), SetSection::parse), + new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("transform_and_set"), TransformAndSetSection::parse), new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("match"), MatchAssertion::parse), new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("is_true"), IsTrueAssertion::parse), new NamedXContentRegistry.Entry(ExecutableSection.class, new ParseField("is_false"), IsFalseAssertion::parse), diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSection.java b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSection.java new file mode 100644 index 0000000000000..7b0b915dd97df --- /dev/null +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSection.java @@ -0,0 +1,106 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.test.rest.yaml.section; + +import org.elasticsearch.common.ParsingException; +import org.elasticsearch.common.xcontent.XContentLocation; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.test.rest.yaml.ClientYamlTestExecutionContext; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents a transform_and_set section: + *

+ * + * In the following example,
+ * - transform_and_set: { login_creds: "#base64EncodeCredentials(user,password)" }
+ * user and password are from the response which are joined by ':' and Base64 encoded and then stashed as 'login_creds' + * + */ +public class TransformAndSetSection implements ExecutableSection { + public static TransformAndSetSection parse(XContentParser parser) throws IOException { + String currentFieldName = null; + XContentParser.Token token; + + TransformAndSetSection transformAndStashSection = new TransformAndSetSection(parser.getTokenLocation()); + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + transformAndStashSection.addSet(currentFieldName, parser.text()); + } + } + + parser.nextToken(); + + if (transformAndStashSection.getStash().isEmpty()) { + throw new ParsingException(transformAndStashSection.location, "transform_and_set section must set at least a value"); + } + + return transformAndStashSection; + } + + private final Map transformStash = new HashMap<>(); + private final XContentLocation location; + + public TransformAndSetSection(XContentLocation location) { + this.location = location; + } + + public void addSet(String stashedField, String transformThis) { + transformStash.put(stashedField, transformThis); + } + + public Map getStash() { + return transformStash; + } + + @Override + public XContentLocation getLocation() { + return location; + } + + @Override + public void execute(ClientYamlTestExecutionContext executionContext) throws IOException { + for (Map.Entry entry : transformStash.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (value.startsWith("#base64EncodeCredentials(") && value.endsWith(")")) { + value = entry.getValue().substring("#base64EncodeCredentials(".length(), entry.getValue().lastIndexOf(")")); + String[] idAndPassword = value.split(","); + if (idAndPassword.length == 2) { + String credentials = executionContext.response(idAndPassword[0].trim()) + ":" + + executionContext.response(idAndPassword[1].trim()); + value = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } else { + throw new IllegalArgumentException("base64EncodeCredentials requires a username/id and a password parameters"); + } + } + executionContext.stash().stashValue(key, value); + } + } + +} diff --git a/test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSectionTests.java b/test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSectionTests.java new file mode 100644 index 0000000000000..a61f91de287e7 --- /dev/null +++ b/test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/TransformAndSetSectionTests.java @@ -0,0 +1,96 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.test.rest.yaml.section; + +import org.elasticsearch.common.ParsingException; +import org.elasticsearch.common.xcontent.yaml.YamlXContent; +import org.elasticsearch.test.rest.yaml.ClientYamlTestExecutionContext; +import org.elasticsearch.test.rest.yaml.Stash; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class TransformAndSetSectionTests extends AbstractClientYamlTestFragmentParserTestCase { + + public void testParseSingleValue() throws Exception { + parser = createParser(YamlXContent.yamlXContent, + "{ key: value }" + ); + + TransformAndSetSection transformAndSet = TransformAndSetSection.parse(parser); + assertThat(transformAndSet, notNullValue()); + assertThat(transformAndSet.getStash(), notNullValue()); + assertThat(transformAndSet.getStash().size(), equalTo(1)); + assertThat(transformAndSet.getStash().get("key"), equalTo("value")); + } + + public void testParseMultipleValues() throws Exception { + parser = createParser(YamlXContent.yamlXContent, + "{ key1: value1, key2: value2 }" + ); + + TransformAndSetSection transformAndSet = TransformAndSetSection.parse(parser); + assertThat(transformAndSet, notNullValue()); + assertThat(transformAndSet.getStash(), notNullValue()); + assertThat(transformAndSet.getStash().size(), equalTo(2)); + assertThat(transformAndSet.getStash().get("key1"), equalTo("value1")); + assertThat(transformAndSet.getStash().get("key2"), equalTo("value2")); + } + + public void testTransformation() throws Exception { + parser = createParser(YamlXContent.yamlXContent, "{ login_creds: \"#base64EncodeCredentials(id,api_key)\" }"); + + TransformAndSetSection transformAndSet = TransformAndSetSection.parse(parser); + assertThat(transformAndSet, notNullValue()); + assertThat(transformAndSet.getStash(), notNullValue()); + assertThat(transformAndSet.getStash().size(), equalTo(1)); + assertThat(transformAndSet.getStash().get("login_creds"), equalTo("#base64EncodeCredentials(id,api_key)")); + + ClientYamlTestExecutionContext executionContext = mock(ClientYamlTestExecutionContext.class); + when(executionContext.response("id")).thenReturn("user"); + when(executionContext.response("api_key")).thenReturn("password"); + Stash stash = new Stash(); + when(executionContext.stash()).thenReturn(stash); + transformAndSet.execute(executionContext); + verify(executionContext).response("id"); + verify(executionContext).response("api_key"); + verify(executionContext).stash(); + assertThat(stash.getValue("$login_creds"), + equalTo(Base64.getEncoder().encodeToString("user:password".getBytes(StandardCharsets.UTF_8)))); + verifyNoMoreInteractions(executionContext); + } + + public void testParseSetSectionNoValues() throws Exception { + parser = createParser(YamlXContent.yamlXContent, + "{ }" + ); + + Exception e = expectThrows(ParsingException.class, () -> TransformAndSetSection.parse(parser)); + assertThat(e.getMessage(), is("transform_and_set section must set at least a value")); + } +} diff --git a/x-pack/docs/en/rest-api/security.asciidoc b/x-pack/docs/en/rest-api/security.asciidoc index c17311ea12fa7..49dc1306ed643 100644 --- a/x-pack/docs/en/rest-api/security.asciidoc +++ b/x-pack/docs/en/rest-api/security.asciidoc @@ -55,10 +55,11 @@ without requiring basic authentication: [[security-api-keys]] === API Keys -You can use the following API to create API keys for access +You can use the following APIs to create and invalidate API keys for access without requiring basic authentication: * <> +* <> [float] [[security-user-apis]] @@ -98,3 +99,4 @@ include::security/has-privileges.asciidoc[] include::security/invalidate-tokens.asciidoc[] include::security/ssl.asciidoc[] include::security/create-api-keys.asciidoc[] +include::security/invalidate-api-keys.asciidoc[] diff --git a/x-pack/docs/en/rest-api/security/invalidate-api-keys.asciidoc b/x-pack/docs/en/rest-api/security/invalidate-api-keys.asciidoc new file mode 100644 index 0000000000000..4809e267ebd80 --- /dev/null +++ b/x-pack/docs/en/rest-api/security/invalidate-api-keys.asciidoc @@ -0,0 +1,140 @@ +[role="xpack"] +[[security-api-invalidate-api-key]] +=== Invalidate API Key API +++++ +Invalidate API key +++++ + +Invalidates one or more API keys. + +==== Request + +`DELETE /_security/api_key` + +==== Description + +The API keys created by <> can be invalidated +using this API. + +==== Request Body + +The following parameters can be specified in the body of a DELETE request and +pertain to invalidating api keys: + +`id` (optional):: +(string) An API key id. This parameter cannot be used with any of `name`, `realm_name` or + `username` are used. + +`name` (optional):: +(string) An API key name. This parameter cannot be used with any of `id`, `realm_name` or + `username` are used. + +`realm_name` (optional):: +(string) The name of an authentication realm. This parameter cannot be used with either `api_key_id` or `api_key_name`. + +`username` (optional):: +(string) The username of a user. This parameter cannot be used with either `api_key_id` or `api_key_name`. + +NOTE: While all parameters are optional, at least one of them is required. + +==== Examples + +The following example invalidates the API key identified by specified `id` immediately: + +[source,js] +-------------------------------------------------- +DELETE /_security/api_key +{ + "id" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==" +} +-------------------------------------------------- +// NOTCONSOLE + +whereas the following example invalidates the API key identified by specified `name` immediately: + +[source,js] +-------------------------------------------------- +DELETE /_security/api_key +{ + "name" : "hadoop_myuser_key" +} +-------------------------------------------------- +// NOTCONSOLE + +The following example invalidates all API keys for the `native1` realm immediately: + +[source,js] +-------------------------------------------------- +DELETE /_xpack/api_key +{ + "realm_name" : "native1" +} +-------------------------------------------------- +// NOTCONSOLE + +The following example invalidates all API keys for the user `myuser` in all realms immediately: + +[source,js] +-------------------------------------------------- +DELETE /_xpack/api_key +{ + "username" : "myuser" +} +-------------------------------------------------- +// NOTCONSOLE + +Finally, the following example invalidates all API keys for the user `myuser` in + the `native1` realm immediately: + +[source,js] +-------------------------------------------------- +DELETE /_xpack/api_key +{ + "username" : "myuser", + "realm_name" : "native1" +} +-------------------------------------------------- +// NOTCONSOLE + +A successful call returns a JSON structure that contains the ids of the API keys that were invalidated, the ids +of the API keys that had already been invalidated, and potentially a list of errors encountered while invalidating +specific api keys. + +[source,js] +-------------------------------------------------- +{ + "invalidated_api_keys": [ <1> + "api-key-id-1" + ], + "previously_invalidated_api_keys": [ <2> + "api-key-id-2", + "api-key-id-3" + ], + "error_count": 2, <3> + "error_details": [ <4> + { + "type": "exception", + "reason": "error occurred while invalidating api keys", + "caused_by": { + "type": "illegal_argument_exception", + "reason": "invalid api key id" + } + }, + { + "type": "exception", + "reason": "error occurred while invalidating api keys", + "caused_by": { + "type": "illegal_argument_exception", + "reason": "invalid api key id" + } + } + ] +} +-------------------------------------------------- +// NOTCONSOLE + +<1> The ids of the API keys that were invalidated as part of this request. +<2> The ids of the API keys that were already invalidated. +<3> The number of errors that were encountered when invalidating the API keys. +<4> Details about these errors. This field is not present in the response when + `error_count` is 0. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java index b60267d603155..b0b5914d94f9d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java @@ -24,8 +24,8 @@ public final class InvalidateApiKeyRequest extends ActionRequest { private final String realmName; private final String userName; - private final String apiKeyId; - private final String apiKeyName; + private final String id; + private final String name; public InvalidateApiKeyRequest() { this(null, null, null, null); @@ -35,16 +35,16 @@ public InvalidateApiKeyRequest(StreamInput in) throws IOException { super(in); realmName = in.readOptionalString(); userName = in.readOptionalString(); - apiKeyId = in.readOptionalString(); - apiKeyName = in.readOptionalString(); + id = in.readOptionalString(); + name = in.readOptionalString(); } - private InvalidateApiKeyRequest(@Nullable String realmName, @Nullable String userName, @Nullable String apiKeyId, - @Nullable String apiKeyName) { + public InvalidateApiKeyRequest(@Nullable String realmName, @Nullable String userName, @Nullable String id, + @Nullable String name) { this.realmName = realmName; this.userName = userName; - this.apiKeyId = apiKeyId; - this.apiKeyName = apiKeyName; + this.id = id; + this.name = name; } public String getRealmName() { @@ -55,12 +55,12 @@ public String getUserName() { return userName; } - public String getApiKeyId() { - return apiKeyId; + public String getId() { + return id; } - public String getApiKeyName() { - return apiKeyName; + public String getName() { + return name; } /** @@ -93,38 +93,38 @@ public static InvalidateApiKeyRequest usingRealmAndUserName(String realmName, St /** * Creates invalidate API key request for given api key id - * @param apiKeyId api key id + * @param id api key id * @return {@link InvalidateApiKeyRequest} */ - public static InvalidateApiKeyRequest usingApiKeyId(String apiKeyId) { - return new InvalidateApiKeyRequest(null, null, apiKeyId, null); + public static InvalidateApiKeyRequest usingApiKeyId(String id) { + return new InvalidateApiKeyRequest(null, null, id, null); } /** * Creates invalidate api key request for given api key name - * @param apiKeyName api key name + * @param name api key name * @return {@link InvalidateApiKeyRequest} */ - public static InvalidateApiKeyRequest usingApiKeyName(String apiKeyName) { - return new InvalidateApiKeyRequest(null, null, null, apiKeyName); + public static InvalidateApiKeyRequest usingApiKeyName(String name) { + return new InvalidateApiKeyRequest(null, null, null, name); } @Override public ActionRequestValidationException validate() { ActionRequestValidationException validationException = null; - if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false && Strings.hasText(apiKeyId) == false - && Strings.hasText(apiKeyName) == false) { + if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false && Strings.hasText(id) == false + && Strings.hasText(name) == false) { validationException = addValidationError("One of [api key id, api key name, username, realm name] must be specified", null); } if (Strings.hasText(realmName) || Strings.hasText(userName)) { - if (Strings.hasText(apiKeyId)) { + if (Strings.hasText(id)) { validationException = addValidationError("api key id must not be specified when username or realm name is specified", null); } - if (Strings.hasText(apiKeyName)) { + if (Strings.hasText(name)) { validationException = addValidationError("api key name must not be specified when username or realm name is specified", validationException); } - } else if (Strings.hasText(apiKeyId) && Strings.hasText(apiKeyName)) { + } else if (Strings.hasText(id) && Strings.hasText(name)) { validationException = addValidationError("api key name must not be specified when api key id is specified", null); } return validationException; @@ -135,8 +135,8 @@ public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); out.writeOptionalString(realmName); out.writeOptionalString(userName); - out.writeOptionalString(apiKeyId); - out.writeOptionalString(apiKeyName); + out.writeOptionalString(id); + out.writeOptionalString(name); } @Override diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java index 1db4f25e96c2f..26a0e008eff3e 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java @@ -9,18 +9,24 @@ import org.elasticsearch.ElasticsearchException; import org.elasticsearch.action.ActionResponse; import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; import org.elasticsearch.common.Strings; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Objects; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + /** * Response for invalidation of one or more API keys result.
* The result contains information about: @@ -109,6 +115,23 @@ public void writeTo(StreamOutput out) throws IOException { out.writeCollection(errors, StreamOutput::writeException); } + static ConstructingObjectParser PARSER = new ConstructingObjectParser<>("invalidate_api_key_response", + args -> { + return new InvalidateApiKeyResponse((List) args[0], (List) args[1], (List) args[3]); + }); + static { + PARSER.declareStringArray(constructorArg(), new ParseField("invalidated_api_keys")); + PARSER.declareStringArray(constructorArg(), new ParseField("previously_invalidated_api_keys")); + // we parse error_count but ignore it while constructing response + PARSER.declareInt(constructorArg(), new ParseField("error_count")); + PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> ElasticsearchException.fromXContent(p), + new ParseField("error_details")); + } + + public static InvalidateApiKeyResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + @Override public String toString() { return "ApiKeysInvalidationResult [invalidatedApiKeys=" + invalidatedApiKeys + ", previouslyInvalidatedApiKeys=" 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 433fcce603c34..a990b32640d2d 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 @@ -198,6 +198,7 @@ import org.elasticsearch.xpack.security.rest.SecurityRestFilter; import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction; import org.elasticsearch.xpack.security.rest.action.RestCreateApiKeyAction; +import org.elasticsearch.xpack.security.rest.action.RestInvalidateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestDeletePrivilegesAction; @@ -809,7 +810,8 @@ public List getRestHandlers(Settings settings, RestController restC new RestGetPrivilegesAction(settings, restController, getLicenseState()), new RestPutPrivilegesAction(settings, restController, getLicenseState()), new RestDeletePrivilegesAction(settings, restController, getLicenseState()), - new RestCreateApiKeyAction(settings, restController, getLicenseState()) + new RestCreateApiKeyAction(settings, restController, getLicenseState()), + new RestInvalidateApiKeyAction(settings, restController, getLicenseState()) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java index 80a74037657e1..886d15b1f257d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportInvalidateApiKeyAction.java @@ -34,10 +34,10 @@ public TransportInvalidateApiKeyAction(TransportService transportService, Action protected void doExecute(Task task, InvalidateApiKeyRequest request, ActionListener listener) { if (Strings.hasText(request.getRealmName()) || Strings.hasText(request.getUserName())) { apiKeyService.invalidateApiKeysForRealmAndUser(request.getRealmName(), request.getUserName(), listener); - } else if (Strings.hasText(request.getApiKeyId())) { - apiKeyService.invalidateApiKeyForApiKeyId(request.getApiKeyId(), listener); + } else if (Strings.hasText(request.getId())) { + apiKeyService.invalidateApiKeyForApiKeyId(request.getId(), listener); } else { - apiKeyService.invalidateApiKeyForApiKeyName(request.getApiKeyName(), listener); + apiKeyService.invalidateApiKeyForApiKeyName(request.getName(), listener); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyAction.java new file mode 100644 index 0000000000000..9a65b3e3965c5 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyAction.java @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.BytesRestResponse; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestResponse; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.rest.action.RestBuilderListener; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; + +import java.io.IOException; + +/** + * Rest action to invalidate one or more API keys + */ +public final class RestInvalidateApiKeyAction extends SecurityBaseRestHandler { + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>("invalidate_api_key", + a -> { + return new InvalidateApiKeyRequest((String) a[0], (String) a[1], (String) a[2], (String) a[3]); + }); + + static { + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("realm_name")); + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("username")); + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("id")); + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("name")); + } + + public RestInvalidateApiKeyAction(Settings settings, RestController controller, XPackLicenseState licenseState) { + super(settings, licenseState); + controller.registerHandler(RestRequest.Method.DELETE, "/_security/api_key", this); + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { + try (XContentParser parser = request.contentParser()) { + final InvalidateApiKeyRequest invalidateApiKeyRequest = PARSER.parse(parser, null); + return channel -> client.execute(InvalidateApiKeyAction.INSTANCE, invalidateApiKeyRequest, + new RestBuilderListener(channel) { + @Override + public RestResponse buildResponse(InvalidateApiKeyResponse invalidateResp, + XContentBuilder builder) throws Exception { + invalidateResp.toXContent(builder, channel.request()); + return new BytesRestResponse(RestStatus.OK, builder); + } + }); + } + } + + @Override + public String getName() { + return "xpack_security_invalidate_api_key"; + } + + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyActionTests.java new file mode 100644 index 0000000000000..91a458ff66cf7 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/RestInvalidateApiKeyActionTests.java @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action; + +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.Action; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.AbstractRestChannel; +import org.elasticsearch.rest.RestChannel; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestResponse; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class RestInvalidateApiKeyActionTests extends ESTestCase { + private final XPackLicenseState mockLicenseState = mock(XPackLicenseState.class); + private final RestController mockRestController = mock(RestController.class); + private Settings settings = null; + private ThreadPool threadPool = null; + + @Override + public void setUp() throws Exception { + super.setUp(); + settings = Settings.builder() + .put("path.home", createTempDir().toString()) + .put("node.name", "test-" + getTestName()) + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .build(); + threadPool = new ThreadPool(settings); + when(mockLicenseState.isSecurityAvailable()).thenReturn(true); + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + terminate(threadPool); + } + + public void testInvalidateApiKey() throws Exception { + final String json1 = "{ \"realm_name\" : \"realm-1\", \"username\": \"user-x\" }"; + final String json2 = "{ \"realm_name\" : \"realm-1\" }"; + final String json3 = "{ \"username\": \"user-x\" }"; + final String json4 = "{ \"id\" : \"api-key-id-1\" }"; + final String json5 = "{ \"name\" : \"api-key-name-1\" }"; + final List jsons = Arrays.asList(json1, json2, json3, json4, json5); + final String json = randomFrom(json1, json2, json3, json4, json5); + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY) + .withContent(new BytesArray(json), XContentType.JSON).build(); + + final SetOnce responseSetOnce = new SetOnce<>(); + final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) { + @Override + public void sendResponse(RestResponse restResponse) { + responseSetOnce.set(restResponse); + } + }; + + final InvalidateApiKeyResponse invalidateApiKeyResponseExpected = new InvalidateApiKeyResponse( + Collections.singletonList("api-key-id-1"), Collections.emptyList(), null); + + try (NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute(Action action, Request request, + ActionListener listener) { + InvalidateApiKeyRequest invalidateApiKeyRequest = (InvalidateApiKeyRequest) request; + ActionRequestValidationException validationException = invalidateApiKeyRequest.validate(); + if (validationException != null) { + listener.onFailure(validationException); + return; + } + if (invalidateApiKeyRequest.getName() != null && invalidateApiKeyRequest.getName().equals("api-key-name-1") + || invalidateApiKeyRequest.getId() != null && invalidateApiKeyRequest.getId().equals("api-key-id-1") + || invalidateApiKeyRequest.getRealmName() != null && invalidateApiKeyRequest.getRealmName().equals("realm-1") + || invalidateApiKeyRequest.getUserName() != null && invalidateApiKeyRequest.getUserName().equals("user-x")) { + listener.onResponse((Response) invalidateApiKeyResponseExpected); + } else { + listener.onFailure(new ElasticsearchSecurityException("encountered an error while creating API key")); + } + } + }) { + final RestInvalidateApiKeyAction restInvalidateApiKeyAction = new RestInvalidateApiKeyAction(Settings.EMPTY, mockRestController, + mockLicenseState); + + restInvalidateApiKeyAction.handleRequest(restRequest, restChannel, client); + + final RestResponse restResponse = responseSetOnce.get(); + assertNotNull(restResponse); + final InvalidateApiKeyResponse actual = InvalidateApiKeyResponse + .fromXContent(createParser(XContentType.JSON.xContent(), restResponse.content())); + assertThat(actual.getInvalidatedApiKeys(), equalTo(invalidateApiKeyResponseExpected.getInvalidatedApiKeys())); + assertThat(actual.getPreviouslyInvalidatedApiKeys(), + equalTo(invalidateApiKeyResponseExpected.getPreviouslyInvalidatedApiKeys())); + assertThat(actual.getErrors(), equalTo(invalidateApiKeyResponseExpected.getErrors())); + + } + + } +} diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/api/security.invalidate_api_key.json b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.invalidate_api_key.json new file mode 100644 index 0000000000000..fa0639704aa9a --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.invalidate_api_key.json @@ -0,0 +1,15 @@ +{ + "security.invalidate_api_key": { + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-invalidate-api-key.html", + "methods": [ "DELETE" ], + "url": { + "path": "/_security/api_key", + "paths": [ "/_security/api_key" ], + "parts": {} + }, + "body": { + "description" : "The api key request to invalidate API key(s)", + "required" : true + } + } +} diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml index 4737cc003ca11..2ce6532d8255b 100644 --- a/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml @@ -7,10 +7,29 @@ setup: cluster.health: wait_for_status: yellow + - do: + security.put_user: + username: "api_key_user" + body: > + { + "password" : "x-pack-test-password", + "roles" : [ "superuser" ], + "full_name" : "API key user" + } + +--- +teardown: + - do: + security.delete_user: + username: "api_key_user" + ignore: 404 + --- "Test create api key": - do: + headers: + Authorization: "Basic YXBpX2tleV91c2VyOngtcGFjay10ZXN0LXBhc3N3b3Jk" security.create_api_key: body: > { @@ -18,7 +37,7 @@ setup: "expiration": "1d", "role_descriptors": { "role-a": { - "cluster": ["a-1"], + "cluster": ["all"], "index": [ { "names": ["index-a"], @@ -27,7 +46,7 @@ setup: ] }, "role-b": { - "cluster": ["b-1"], + "cluster": ["manage"], "index": [ { "names": ["index-b"], @@ -41,3 +60,69 @@ setup: - is_true: id - is_true: api_key - is_true: expiration + - transform_and_set: { login_creds: "#base64EncodeCredentials(id,api_key)" } + + - do: + headers: + Authorization: ApiKey ${login_creds} + security.authenticate: {} + + - match: { username: "api_key_user" } + - match: { roles.0: "role-b" } + - match: { roles.1: "role-a" } + - match: { authentication_realm.name: "_es_api_key" } + - match: { authentication_realm.type: "_es_api_key" } + +--- +"Test invalidate api key": + - skip: + features: transform_and_set + + - do: + headers: + Authorization: "Basic YXBpX2tleV91c2VyOngtcGFjay10ZXN0LXBhc3N3b3Jk" + security.create_api_key: + body: > + { + "name": "my-api-key-1", + "expiration": "1d", + "role_descriptors": { + "role-a": { + "cluster": ["all"], + "index": [ + { + "names": ["index-a"], + "privileges": ["read"] + } + ] + }, + "role-b": { + "cluster": ["manage"], + "index": [ + { + "names": ["index-b"], + "privileges": ["all"] + } + ] + } + } + } + - match: { name: "my-api-key-1" } + - is_true: id + - is_true: api_key + - is_true: expiration + - set: { id: api_key_id } + - transform_and_set: { login_creds: "#base64EncodeCredentials(id,api_key)" } + + - do: + headers: + Authorization: Apikey ${login_creds} + security.invalidate_api_key: + body: > + { + "id": "${api_key_id}" + } + - length: { "invalidated_api_keys" : 1 } + - match: { "invalidated_api_keys.0" : "${api_key_id}" } + - length: { "previously_invalidated_api_keys" : 0 } + - match: { "error_count" : 0 } From 3988a9204bc1eb43e0818f51c756e5a040832995 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Thu, 24 Jan 2019 07:32:11 +1100 Subject: [PATCH 18/26] Get API keys API transport action (#37632) This commit adds get API keys API transport action. Users can request API key information for:- - all the keys for a given username - all the keys for a given realm name - all the keys for a given realm and username - for a given api key id - for a given api key name Follow up PRs: Rest action and HLRC changes for the get API keys API. --- .../xpack/core/XPackClientPlugin.java | 2 + .../xpack/core/security/action/ApiKey.java | 141 ++++++++++ .../core/security/action/GetApiKeyAction.java | 33 +++ .../security/action/GetApiKeyRequest.java | 146 ++++++++++ .../security/action/GetApiKeyResponse.java | 70 +++++ .../action/InvalidateApiKeyRequest.java | 18 +- .../action/InvalidateApiKeyResponse.java | 2 +- .../core/security/client/SecurityClient.java | 7 + .../action/GetApiKeyRequestTests.java | 103 +++++++ .../action/GetApiKeyResponseTests.java | 64 +++++ .../action/InvalidateApiKeyRequestTests.java | 78 +++--- .../xpack/security/Security.java | 5 +- .../action/TransportGetApiKeyAction.java | 46 ++++ .../xpack/security/authc/ApiKeyService.java | 178 +++++++++++-- .../security/authc/ApiKeyIntegTests.java | 252 ++++++++++++++---- .../rest-api-spec/test/api_key/10_basic.yml | 18 -- 16 files changed, 1008 insertions(+), 155 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequest.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponse.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequestTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGetApiKeyAction.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java index 6266147649f74..6265132fb5e69 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java @@ -137,6 +137,7 @@ import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction; import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction; @@ -317,6 +318,7 @@ public List> getClientActions() { RefreshTokenAction.INSTANCE, CreateApiKeyAction.INSTANCE, InvalidateApiKeyAction.INSTANCE, + GetApiKeyAction.INSTANCE, // upgrade IndexUpgradeInfoAction.INSTANCE, IndexUpgradeAction.INSTANCE, diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java new file mode 100644 index 0000000000000..64db60d60b0e0 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; + +/** + * API key information + */ +public final class ApiKey implements ToXContentObject, Writeable { + + private final String name; + private final String id; + private final Instant creation; + private final Instant expiration; + private final boolean invalidated; + private final String username; + private final String realm; + + public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm) { + this.name = name; + this.id = id; + // As we do not yet support the nanosecond precision when we serialize to JSON, + // here creating the 'Instant' of milliseconds precision. + // This Instant can then be used for date comparison. + this.creation = Instant.ofEpochMilli(creation.toEpochMilli()); + this.expiration = (expiration != null) ? Instant.ofEpochMilli(expiration.toEpochMilli()): null; + this.invalidated = invalidated; + this.username = username; + this.realm = realm; + } + + public ApiKey(StreamInput in) throws IOException { + this.name = in.readString(); + this.id = in.readString(); + this.creation = in.readInstant(); + this.expiration = in.readOptionalInstant(); + this.invalidated = in.readBoolean(); + this.username = in.readString(); + this.realm = in.readString(); + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Instant getCreation() { + return creation; + } + + public Instant getExpiration() { + return expiration; + } + + public boolean isInvalidated() { + return invalidated; + } + + public String getUsername() { + return username; + } + + public String getRealm() { + return realm; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject() + .field("id", id) + .field("name", name) + .field("creation", creation.toEpochMilli()); + if (expiration != null) { + builder.field("expiration", expiration.toEpochMilli()); + } + builder.field("invalidated", invalidated) + .field("username", username) + .field("realm", realm); + return builder.endObject(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeString(id); + out.writeInstant(creation); + out.writeOptionalInstant(expiration); + out.writeBoolean(invalidated); + out.writeString(username); + out.writeString(realm); + } + + @Override + public int hashCode() { + return Objects.hash(name, id, creation, expiration, invalidated, username, realm); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ApiKey other = (ApiKey) obj; + return Objects.equals(name, other.name) + && Objects.equals(id, other.id) + && Objects.equals(creation, other.creation) + && Objects.equals(expiration, other.expiration) + && Objects.equals(invalidated, other.invalidated) + && Objects.equals(username, other.username) + && Objects.equals(realm, other.realm); + } + + @Override + public String toString() { + return "ApiKeyInfo [name=" + name + ", id=" + id + ", creation=" + creation + ", expiration=" + expiration + ", invalidated=" + + invalidated + ", username=" + username + ", realm=" + realm + "]"; + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyAction.java new file mode 100644 index 0000000000000..2af331909a3af --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyAction.java @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.Action; +import org.elasticsearch.common.io.stream.Writeable; + +/** + * Action for retrieving API key(s) + */ +public final class GetApiKeyAction extends Action { + + public static final String NAME = "cluster:admin/xpack/security/api_key/get"; + public static final GetApiKeyAction INSTANCE = new GetApiKeyAction(); + + private GetApiKeyAction() { + super(NAME); + } + + @Override + public GetApiKeyResponse newResponse() { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public Writeable.Reader getResponseReader() { + return GetApiKeyResponse::new; + } +} \ No newline at end of file diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequest.java new file mode 100644 index 0000000000000..08791675cc52b --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequest.java @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +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 java.io.IOException; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +/** + * Request for get API key + */ +public final class GetApiKeyRequest extends ActionRequest { + + private final String realmName; + private final String userName; + private final String apiKeyId; + private final String apiKeyName; + + public GetApiKeyRequest() { + this(null, null, null, null); + } + + public GetApiKeyRequest(StreamInput in) throws IOException { + super(in); + realmName = in.readOptionalString(); + userName = in.readOptionalString(); + apiKeyId = in.readOptionalString(); + apiKeyName = in.readOptionalString(); + } + + private GetApiKeyRequest(@Nullable String realmName, @Nullable String userName, @Nullable String apiKeyId, + @Nullable String apiKeyName) { + this.realmName = realmName; + this.userName = userName; + this.apiKeyId = apiKeyId; + this.apiKeyName = apiKeyName; + } + + public String getRealmName() { + return realmName; + } + + public String getUserName() { + return userName; + } + + public String getApiKeyId() { + return apiKeyId; + } + + public String getApiKeyName() { + return apiKeyName; + } + + /** + * Creates get API key request for given realm name + * @param realmName realm name + * @return {@link GetApiKeyRequest} + */ + public static GetApiKeyRequest usingRealmName(String realmName) { + return new GetApiKeyRequest(realmName, null, null, null); + } + + /** + * Creates get API key request for given user name + * @param userName user name + * @return {@link GetApiKeyRequest} + */ + public static GetApiKeyRequest usingUserName(String userName) { + return new GetApiKeyRequest(null, userName, null, null); + } + + /** + * Creates get API key request for given realm and user name + * @param realmName realm name + * @param userName user name + * @return {@link GetApiKeyRequest} + */ + public static GetApiKeyRequest usingRealmAndUserName(String realmName, String userName) { + return new GetApiKeyRequest(realmName, userName, null, null); + } + + /** + * Creates get API key request for given api key id + * @param apiKeyId api key id + * @return {@link GetApiKeyRequest} + */ + public static GetApiKeyRequest usingApiKeyId(String apiKeyId) { + return new GetApiKeyRequest(null, null, apiKeyId, null); + } + + /** + * Creates get api key request for given api key name + * @param apiKeyName api key name + * @return {@link GetApiKeyRequest} + */ + public static GetApiKeyRequest usingApiKeyName(String apiKeyName) { + return new GetApiKeyRequest(null, null, null, apiKeyName); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false && Strings.hasText(apiKeyId) == false + && Strings.hasText(apiKeyName) == false) { + validationException = addValidationError("One of [api key id, api key name, username, realm name] must be specified", + validationException); + } + if (Strings.hasText(apiKeyId) || Strings.hasText(apiKeyName)) { + if (Strings.hasText(realmName) || Strings.hasText(userName)) { + validationException = addValidationError( + "username or realm name must not be specified when the api key id or api key name is specified", + validationException); + } + } + if (Strings.hasText(apiKeyId) && Strings.hasText(apiKeyName)) { + validationException = addValidationError("only one of [api key id, api key name] can be specified", validationException); + } + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalString(realmName); + out.writeOptionalString(userName); + out.writeOptionalString(apiKeyId); + out.writeOptionalString(apiKeyName); + } + + @Override + public void readFrom(StreamInput in) throws IOException { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponse.java new file mode 100644 index 0000000000000..17afb93668288 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponse.java @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; + +/** + * Response for get API keys.
+ * The result contains information about the API keys that were found. + */ +public final class GetApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable { + + private final ApiKey[] foundApiKeysInfo; + + public GetApiKeyResponse(StreamInput in) throws IOException { + super(in); + this.foundApiKeysInfo = in.readArray(ApiKey::new, ApiKey[]::new); + } + + public GetApiKeyResponse(Collection foundApiKeysInfo) { + Objects.requireNonNull(foundApiKeysInfo, "found_api_keys_info must be provided"); + this.foundApiKeysInfo = foundApiKeysInfo.toArray(new ApiKey[0]); + } + + public static GetApiKeyResponse emptyResponse() { + return new GetApiKeyResponse(Collections.emptyList()); + } + + public ApiKey[] getApiKeyInfos() { + return foundApiKeysInfo; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject() + .array("api_keys", (Object[]) foundApiKeysInfo); + return builder.endObject(); + } + + @Override + public void readFrom(StreamInput in) throws IOException { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeArray(foundApiKeysInfo); + } + + @Override + public String toString() { + return "GetApiKeyResponse [foundApiKeysInfo=" + foundApiKeysInfo + "]"; + } + +} \ No newline at end of file diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java index b0b5914d94f9d..f8815785d53d8 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequest.java @@ -114,18 +114,18 @@ public ActionRequestValidationException validate() { ActionRequestValidationException validationException = null; if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false && Strings.hasText(id) == false && Strings.hasText(name) == false) { - validationException = addValidationError("One of [api key id, api key name, username, realm name] must be specified", null); + validationException = addValidationError("One of [api key id, api key name, username, realm name] must be specified", + validationException); } - if (Strings.hasText(realmName) || Strings.hasText(userName)) { - if (Strings.hasText(id)) { - validationException = addValidationError("api key id must not be specified when username or realm name is specified", null); - } - if (Strings.hasText(name)) { - validationException = addValidationError("api key name must not be specified when username or realm name is specified", + if (Strings.hasText(id) || Strings.hasText(name)) { + if (Strings.hasText(realmName) || Strings.hasText(userName)) { + validationException = addValidationError( + "username or realm name must not be specified when the api key id or api key name is specified", validationException); } - } else if (Strings.hasText(id) && Strings.hasText(name)) { - validationException = addValidationError("api key name must not be specified when api key id is specified", null); + } + if (Strings.hasText(id) && Strings.hasText(name)) { + validationException = addValidationError("only one of [api key id, api key name] can be specified", validationException); } return validationException; } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java index 26a0e008eff3e..823e8c0c204af 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyResponse.java @@ -134,7 +134,7 @@ public static InvalidateApiKeyResponse fromXContent(XContentParser parser) throw @Override public String toString() { - return "ApiKeysInvalidationResult [invalidatedApiKeys=" + invalidatedApiKeys + ", previouslyInvalidatedApiKeys=" + return "InvalidateApiKeyResponse [invalidatedApiKeys=" + invalidatedApiKeys + ", previouslyInvalidatedApiKeys=" + previouslyInvalidatedApiKeys + ", errors=" + errors + "]"; } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java index 58508651e2271..4619035d0daaf 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/client/SecurityClient.java @@ -14,6 +14,9 @@ import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; @@ -358,6 +361,10 @@ public void invalidateApiKey(InvalidateApiKeyRequest request, ActionListener listener) { + client.execute(GetApiKeyAction.INSTANCE, request, listener); + } + public SamlAuthenticateRequestBuilder prepareSamlAuthenticate(byte[] xmlContent, List validIds) { final SamlAuthenticateRequestBuilder builder = new SamlAuthenticateRequestBuilder(client); builder.saml(xmlContent); diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequestTests.java new file mode 100644 index 0000000000000..27be0d88eb82c --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyRequestTests.java @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.InputStreamStreamInput; +import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.test.ESTestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.hamcrest.Matchers.containsInAnyOrder; + +public class GetApiKeyRequestTests extends ESTestCase { + + public void testRequestValidation() { + GetApiKeyRequest request = GetApiKeyRequest.usingApiKeyId(randomAlphaOfLength(5)); + ActionRequestValidationException ve = request.validate(); + assertNull(ve); + request = GetApiKeyRequest.usingApiKeyName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = GetApiKeyRequest.usingRealmName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = GetApiKeyRequest.usingUserName(randomAlphaOfLength(5)); + ve = request.validate(); + assertNull(ve); + request = GetApiKeyRequest.usingRealmAndUserName(randomAlphaOfLength(5), randomAlphaOfLength(7)); + ve = request.validate(); + assertNull(ve); + } + + public void testRequestValidationFailureScenarios() throws IOException { + class Dummy extends ActionRequest { + String realm; + String user; + String apiKeyId; + String apiKeyName; + + Dummy(String[] a) { + realm = a[0]; + user = a[1]; + apiKeyId = a[2]; + apiKeyName = a[3]; + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalString(realm); + out.writeOptionalString(user); + out.writeOptionalString(apiKeyId); + out.writeOptionalString(apiKeyName); + } + } + + String[][] inputs = new String[][] { + { randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), + randomFrom(new String[] { null, "" }) }, + { randomFrom(new String[] { null, "" }), "user", "api-kid", "api-kname" }, + { "realm", randomFrom(new String[] { null, "" }), "api-kid", "api-kname" }, + { "realm", "user", "api-kid", randomFrom(new String[] { null, "" }) }, + { randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), "api-kid", "api-kname" } }; + String[][] expectedErrorMessages = new String[][] { { "One of [api key id, api key name, username, realm name] must be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified", + "only one of [api key id, api key name] can be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified", + "only one of [api key id, api key name] can be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified" }, + { "only one of [api key id, api key name] can be specified" } }; + + for (int caseNo = 0; caseNo < inputs.length; caseNo++) { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { + Dummy d = new Dummy(inputs[caseNo]); + d.writeTo(osso); + + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + InputStreamStreamInput issi = new InputStreamStreamInput(bis); + + GetApiKeyRequest request = new GetApiKeyRequest(issi); + ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertEquals(expectedErrorMessages[caseNo].length, ve.validationErrors().size()); + assertThat(ve.validationErrors(), containsInAnyOrder(expectedErrorMessages[caseNo])); + } + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java new file mode 100644 index 0000000000000..c278c135edaf8 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.Matchers.equalTo; + +public class GetApiKeyResponseTests extends ESTestCase { + + public void testSerialization() throws IOException { + boolean withExpiration = randomBoolean(); + ApiKey apiKeyInfo = createApiKeyInfo(randomAlphaOfLength(4), 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()) { + response.writeTo(output); + try (StreamInput input = output.bytes().streamInput()) { + GetApiKeyResponse serialized = new GetApiKeyResponse(input); + assertThat(serialized.getApiKeyInfos(), equalTo(response.getApiKeyInfos())); + } + } + } + + public void testToXContent() throws IOException { + ApiKey apiKeyInfo1 = createApiKeyInfo("name1", "id-1", Instant.ofEpochMilli(100000L), Instant.ofEpochMilli(10000000L), false, + "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)); + XContentBuilder builder = XContentFactory.jsonBuilder(); + response.toXContent(builder, ToXContent.EMPTY_PARAMS); + assertThat(Strings.toString(builder), equalTo( + "{" + + "\"api_keys\":[" + + "{\"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\"}" + + "]" + + "}")); + } + + private ApiKey createApiKeyInfo(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, + String realm) { + return new ApiKey(name, id, creation, expiration, invalidated, username, realm); + } +} + diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java index 3fdd98e5c5183..3d7fd90234286 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/InvalidateApiKeyRequestTests.java @@ -17,7 +17,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.containsInAnyOrder; public class InvalidateApiKeyRequestTests extends ESTestCase { @@ -68,51 +68,37 @@ public void writeTo(StreamOutput out) throws IOException { } } - try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { - Dummy d = new Dummy(new String[] { "realm", "user", "api-kid", "api-kname" }); - d.writeTo(osso); - - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); - InputStreamStreamInput issi = new InputStreamStreamInput(bis); - - InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); - ActionRequestValidationException ve = request.validate(); - assertNotNull(ve); - assertEquals(2, ve.validationErrors().size()); - assertThat(ve.validationErrors().get(0), - containsString("api key id must not be specified when username or realm name is specified")); - assertThat(ve.validationErrors().get(1), - containsString("api key name must not be specified when username or realm name is specified")); - } - - try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { - Dummy d = new Dummy(new String[] { null, null, "api-kid", "api-kname" }); - d.writeTo(osso); - - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); - InputStreamStreamInput issi = new InputStreamStreamInput(bis); - - InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); - ActionRequestValidationException ve = request.validate(); - assertNotNull(ve); - assertEquals(1, ve.validationErrors().size()); - assertThat(ve.validationErrors().get(0), - containsString("api key name must not be specified when api key id is specified")); - } - - try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { - Dummy d = new Dummy(new String[] { "realm", null, null, "api-kname" }); - d.writeTo(osso); - - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); - InputStreamStreamInput issi = new InputStreamStreamInput(bis); - - InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); - ActionRequestValidationException ve = request.validate(); - assertNotNull(ve); - assertEquals(1, ve.validationErrors().size()); - assertThat(ve.validationErrors().get(0), - containsString("api key name must not be specified when username or realm name is specified")); + String[][] inputs = new String[][] { + { randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), + randomFrom(new String[] { null, "" }) }, + { randomFrom(new String[] { null, "" }), "user", "api-kid", "api-kname" }, + { "realm", randomFrom(new String[] { null, "" }), "api-kid", "api-kname" }, + { "realm", "user", "api-kid", randomFrom(new String[] { null, "" }) }, + { randomFrom(new String[] { null, "" }), randomFrom(new String[] { null, "" }), "api-kid", "api-kname" } }; + String[][] expectedErrorMessages = new String[][] { { "One of [api key id, api key name, username, realm name] must be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified", + "only one of [api key id, api key name] can be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified", + "only one of [api key id, api key name] can be specified" }, + { "username or realm name must not be specified when the api key id or api key name is specified" }, + { "only one of [api key id, api key name] can be specified" } }; + + + for (int caseNo = 0; caseNo < inputs.length; caseNo++) { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + OutputStreamStreamOutput osso = new OutputStreamStreamOutput(bos)) { + Dummy d = new Dummy(inputs[caseNo]); + d.writeTo(osso); + + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + InputStreamStreamInput issi = new InputStreamStreamInput(bis); + + InvalidateApiKeyRequest request = new InvalidateApiKeyRequest(issi); + ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertEquals(expectedErrorMessages[caseNo].length, ve.validationErrors().size()); + assertThat(ve.validationErrors(), containsInAnyOrder(expectedErrorMessages[caseNo])); + } } } } 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 a990b32640d2d..8bba02021123d 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 @@ -86,6 +86,7 @@ import org.elasticsearch.xpack.core.security.SecurityField; import org.elasticsearch.xpack.core.security.SecuritySettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; @@ -140,6 +141,7 @@ import org.elasticsearch.xpack.core.ssl.rest.RestGetCertificateInfoAction; import org.elasticsearch.xpack.core.template.TemplateUtils; import org.elasticsearch.xpack.security.action.TransportCreateApiKeyAction; +import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction; import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.interceptor.BulkShardRequestInterceptor; @@ -760,7 +762,8 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class), - new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class) + new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class), + new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGetApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGetApiKeyAction.java new file mode 100644 index 0000000000000..403ce482805a2 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGetApiKeyAction.java @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +public final class TransportGetApiKeyAction extends HandledTransportAction { + + private final ApiKeyService apiKeyService; + + @Inject + public TransportGetApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService) { + super(GetApiKeyAction.NAME, transportService, actionFilters, + (Writeable.Reader) GetApiKeyRequest::new); + this.apiKeyService = apiKeyService; + } + + @Override + protected void doExecute(Task task, GetApiKeyRequest request, ActionListener listener) { + if (Strings.hasText(request.getRealmName()) || Strings.hasText(request.getUserName())) { + apiKeyService.getApiKeysForRealmAndUser(request.getRealmName(), request.getUserName(), listener); + } else if (Strings.hasText(request.getApiKeyId())) { + apiKeyService.getApiKeyForApiKeyId(request.getApiKeyId(), listener); + } else if (Strings.hasText(request.getApiKeyName())) { + apiKeyService.getApiKeyForApiKeyName(request.getApiKeyName(), listener); + } else { + listener.onFailure(new IllegalArgumentException("One of [api key id, api key name, username, realm name] must be specified")); + } + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index b7cf67ce03fca..ca920d4f1fe77 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -51,8 +51,10 @@ import org.elasticsearch.search.SearchHit; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.ScrollHelper; +import org.elasticsearch.xpack.core.security.action.ApiKey; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; @@ -157,7 +159,7 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ * this check is best effort as there could be two nodes executing search and * then index concurrently allowing a duplicate name. */ - findActiveApiKeyForApiKeyName(request.getName(), ActionListener.wrap(apiKeyIds -> { + findApiKeyForApiKeyName(request.getName(), true, true, ActionListener.wrap(apiKeyIds -> { if (apiKeyIds.isEmpty()) { final Instant created = clock.instant(); final Instant expiration = getApiKeyExpiration(created, request); @@ -456,20 +458,20 @@ public void usedDeprecatedField(String usedName, String replacedWith) { * @param invalidateListener listener for {@link InvalidateApiKeyResponse} */ public void invalidateApiKeysForRealmAndUser(String realmName, String userName, - ActionListener invalidateListener) { + ActionListener invalidateListener) { ensureEnabled(); if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false) { logger.trace("No realm name or username provided"); invalidateListener.onFailure(new IllegalArgumentException("realm name or username must be provided")); } else { - findActiveApiKeysForUserAndRealm(userName, realmName, ActionListener.wrap(apiKeyIds -> { - if (apiKeyIds.isEmpty()) { - logger.warn("No active api keys to invalidate for realm [{}] and username [{}]", realmName, userName); - invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); - } else { - invalidateAllApiKeys(apiKeyIds, invalidateListener); - } - }, invalidateListener::onFailure)); + findApiKeysForUserAndRealm(userName, realmName, true, false, ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + logger.warn("No active api keys to invalidate for realm [{}] and username [{}]", realmName, userName); + invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); + } else { + invalidateAllApiKeys(apiKeyIds.stream().map(apiKey -> apiKey.getId()).collect(Collectors.toSet()), invalidateListener); + } + }, invalidateListener::onFailure)); } } @@ -484,7 +486,19 @@ private void invalidateAllApiKeys(Collection apiKeyIds, ActionListener invalidateListener) { ensureEnabled(); - invalidateAllApiKeys(Collections.singleton(apiKeyId), invalidateListener); + if (Strings.hasText(apiKeyId) == false) { + logger.trace("No api key id provided"); + invalidateListener.onFailure(new IllegalArgumentException("api key id must be provided")); + } else { + findApiKeysForApiKeyId(apiKeyId, true, false, ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + logger.warn("No api key to invalidate for api key id [{}]", apiKeyId); + invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); + } else { + invalidateAllApiKeys(apiKeyIds.stream().map(apiKey -> apiKey.getId()).collect(Collectors.toSet()), invalidateListener); + } + }, invalidateListener::onFailure)); + } } /** @@ -498,18 +512,19 @@ public void invalidateApiKeyForApiKeyName(String apiKeyName, ActionListener { - if (apiKeyIds.isEmpty()) { - logger.warn("No api key to invalidate for api key name [{}]", apiKeyName); - invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); - } else { - invalidateAllApiKeys(apiKeyIds, invalidateListener); - } - }, invalidateListener::onFailure)); + findApiKeyForApiKeyName(apiKeyName, true, false, ActionListener.wrap(apiKeyIds -> { + if (apiKeyIds.isEmpty()) { + logger.warn("No api key to invalidate for api key name [{}]", apiKeyName); + invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); + } else { + invalidateAllApiKeys(apiKeyIds.stream().map(apiKey -> apiKey.getId()).collect(Collectors.toSet()), invalidateListener); + } + }, invalidateListener::onFailure)); } } - private void findActiveApiKeysForUserAndRealm(String userName, String realmName, ActionListener> listener) { + private void findApiKeysForUserAndRealm(String userName, String realmName, boolean filterOutInvalidatedKeys, + boolean filterOutExpiredKeys, ActionListener> listener) { final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); if (frozenSecurityIndex.indexExists() == false) { listener.onResponse(Collections.emptyList()); @@ -517,8 +532,7 @@ private void findActiveApiKeysForUserAndRealm(String userName, String realmName, listener.onFailure(frozenSecurityIndex.getUnavailableReason()); } else { final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() - .filter(QueryBuilders.termQuery("doc_type", "api_key")) - .filter(QueryBuilders.termQuery("api_key_invalidated", false)); + .filter(QueryBuilders.termQuery("doc_type", "api_key")); if (Strings.hasText(userName)) { boolQuery.filter(QueryBuilders.termQuery("creator.principal", userName)); } @@ -526,11 +540,22 @@ private void findActiveApiKeysForUserAndRealm(String userName, String realmName, boolQuery.filter(QueryBuilders.termQuery("creator.realm", realmName)); } - findActiveApiKeys(boolQuery, listener); + findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, listener); } } - private void findActiveApiKeys(final BoolQueryBuilder boolQuery, ActionListener> listener) { + private void findApiKeys(final BoolQueryBuilder boolQuery, boolean filterOutInvalidatedKeys, boolean filterOutExpiredKeys, + ActionListener> listener) { + if (filterOutInvalidatedKeys) { + boolQuery.filter(QueryBuilders.termQuery("api_key_invalidated", false)); + } + if (filterOutExpiredKeys) { + final BoolQueryBuilder expiredQuery = QueryBuilders.boolQuery(); + expiredQuery.should(QueryBuilders.rangeQuery("expiration_time").lte(Instant.now().toEpochMilli())); + expiredQuery.should(QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery("expiration_time"))); + boolQuery.filter(expiredQuery); + } + final SearchRequest request = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) .setScroll(DEFAULT_KEEPALIVE_SETTING.get(settings)) .setQuery(boolQuery) @@ -540,10 +565,22 @@ private void findActiveApiKeys(final BoolQueryBuilder boolQuery, ActionListener< .request(); securityIndex.checkIndexVersionThenExecute(listener::onFailure, () -> ScrollHelper.fetchAllByEntity(client, request, listener, - (SearchHit hit) -> hit.getId())); + (SearchHit hit) -> { + Map source = hit.getSourceAsMap(); + String name = (String) source.get("name"); + String id = hit.getId(); + Long creation = (Long) source.get("creation_time"); + Long expiration = (Long) source.get("expiration_time"); + Boolean invalidated = (Boolean) source.get("api_key_invalidated"); + String username = (String) ((Map) source.get("creator")).get("principal"); + String realm = (String) ((Map) source.get("creator")).get("realm"); + return new ApiKey(name, id, Instant.ofEpochMilli(creation), + (expiration != null) ? Instant.ofEpochMilli(expiration) : null, invalidated, username, realm); + })); } - private void findActiveApiKeyForApiKeyName(String apiKeyName, ActionListener> listener) { + private void findApiKeyForApiKeyName(String apiKeyName, boolean filterOutInvalidatedKeys, boolean filterOutExpiredKeys, + ActionListener> listener) { final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); if (frozenSecurityIndex.indexExists() == false) { listener.onResponse(Collections.emptyList()); @@ -551,13 +588,28 @@ private void findActiveApiKeyForApiKeyName(String apiKeyName, ActionListener> listener) { + final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); + if (frozenSecurityIndex.indexExists() == false) { + listener.onResponse(Collections.emptyList()); + } else if (frozenSecurityIndex.isAvailable() == false) { + listener.onFailure(frozenSecurityIndex.getUnavailableReason()); + } else { + final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("doc_type", "api_key")) + .filter(QueryBuilders.termQuery("_id", apiKeyId)); + + findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, listener); } } @@ -675,4 +727,72 @@ private void maybeStartApiKeyRemover() { } } } + + /** + * Get API keys for given realm and user name. + * @param realmName realm name + * @param userName user name + * @param listener listener for {@link GetApiKeyResponse} + */ + public void getApiKeysForRealmAndUser(String realmName, String userName, ActionListener listener) { + ensureEnabled(); + if (Strings.hasText(realmName) == false && Strings.hasText(userName) == false) { + logger.trace("No realm name or username provided"); + listener.onFailure(new IllegalArgumentException("realm name or username must be provided")); + } else { + findApiKeysForUserAndRealm(userName, realmName, false, false, ActionListener.wrap(apiKeyInfos -> { + if (apiKeyInfos.isEmpty()) { + logger.warn("No active api keys found for realm [{}] and username [{}]", realmName, userName); + listener.onResponse(GetApiKeyResponse.emptyResponse()); + } else { + listener.onResponse(new GetApiKeyResponse(apiKeyInfos)); + } + }, listener::onFailure)); + } + } + + /** + * Get API key for given API key id + * @param apiKeyId API key id + * @param listener listener for {@link GetApiKeyResponse} + */ + public void getApiKeyForApiKeyId(String apiKeyId, ActionListener listener) { + ensureEnabled(); + if (Strings.hasText(apiKeyId) == false) { + logger.trace("No api key id provided"); + listener.onFailure(new IllegalArgumentException("api key id must be provided")); + } else { + findApiKeysForApiKeyId(apiKeyId, false, false, ActionListener.wrap(apiKeyInfos -> { + if (apiKeyInfos.isEmpty()) { + logger.warn("No api key found for api key id [{}]", apiKeyId); + listener.onResponse(GetApiKeyResponse.emptyResponse()); + } else { + listener.onResponse(new GetApiKeyResponse(apiKeyInfos)); + } + }, listener::onFailure)); + } + } + + /** + * Get API key for given API key name + * @param apiKeyName API key name + * @param listener listener for {@link GetApiKeyResponse} + */ + public void getApiKeyForApiKeyName(String apiKeyName, ActionListener listener) { + ensureEnabled(); + if (Strings.hasText(apiKeyName) == false) { + logger.trace("No api key name provided"); + listener.onFailure(new IllegalArgumentException("api key name must be provided")); + } else { + findApiKeyForApiKeyName(apiKeyName, false, false, ActionListener.wrap(apiKeyInfos -> { + if (apiKeyInfos.isEmpty()) { + logger.warn("No api key found for api key name [{}]", apiKeyName); + listener.onResponse(GetApiKeyResponse.emptyResponse()); + } else { + listener.onResponse(new GetApiKeyResponse(apiKeyInfos)); + } + }, listener::onFailure)); + } + } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 25140fcd153ad..3450b713781c6 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -12,6 +12,7 @@ import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.client.Client; +import org.elasticsearch.common.Strings; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.index.query.QueryBuilders; @@ -22,6 +23,8 @@ import org.elasticsearch.test.SecuritySettingsSourceField; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; @@ -36,15 +39,18 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -127,8 +133,9 @@ public void testCreateApiKey() { assertThat(e.status(), is(RestStatus.FORBIDDEN)); } - public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() { + public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() throws InterruptedException, ExecutionException { String keyName = randomAlphaOfLength(5); + List responses = new ArrayList<>(); { final RoleDescriptor descriptor = new RoleDescriptor("role", new String[] { "monitor" }, null, null); Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken @@ -138,6 +145,7 @@ public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() { .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); @@ -151,6 +159,19 @@ public void testCreateApiKeyFailsWhenApiKeyWithSameNameAlreadyExists() { .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 listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyName(keyName), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + verifyInvalidateResponse(1, responses, invalidateResponse); + + // try to create API key with same name, should succeed now + CreateApiKeyResponse createResponse = securityClient.prepareCreateApiKey().setName(keyName) + .setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L))) + .setRoleDescriptors(Collections.singletonList(descriptor)).get(); + assertNotNull(createResponse.getId()); + assertNotNull(createResponse.getKey()); } public void testInvalidateApiKeysForRealm() throws InterruptedException, ExecutionException { @@ -162,11 +183,7 @@ public void testInvalidateApiKeysForRealm() throws InterruptedException, Executi PlainActionFuture listener = new PlainActionFuture<>(); securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingRealmName("file"), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(noOfApiKeys)); - assertThat(invalidateResponse.getInvalidatedApiKeys(), - equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); - assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); - assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + verifyInvalidateResponse(noOfApiKeys, responses, invalidateResponse); } public void testInvalidateApiKeysForUser() throws Exception { @@ -178,11 +195,7 @@ public void testInvalidateApiKeysForUser() throws Exception { PlainActionFuture listener = new PlainActionFuture<>(); securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingUserName(SecuritySettingsSource.TEST_SUPERUSER), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(noOfApiKeys)); - assertThat(invalidateResponse.getInvalidatedApiKeys(), - equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); - assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); - assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + verifyInvalidateResponse(noOfApiKeys, responses, invalidateResponse); } public void testInvalidateApiKeysForRealmAndUser() throws InterruptedException, ExecutionException { @@ -194,11 +207,7 @@ public void testInvalidateApiKeysForRealmAndUser() throws InterruptedException, securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingRealmAndUserName("file", SecuritySettingsSource.TEST_SUPERUSER), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); - assertThat(invalidateResponse.getInvalidatedApiKeys(), - equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); - assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); - assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + verifyInvalidateResponse(1, responses, invalidateResponse); } public void testInvalidateApiKeysForApiKeyId() throws InterruptedException, ExecutionException { @@ -209,11 +218,7 @@ public void testInvalidateApiKeysForApiKeyId() throws InterruptedException, Exec PlainActionFuture listener = new PlainActionFuture<>(); securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); - assertThat(invalidateResponse.getInvalidatedApiKeys(), - equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); - assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); - assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + verifyInvalidateResponse(1, responses, invalidateResponse); } public void testInvalidateApiKeysForApiKeyName() throws InterruptedException, ExecutionException { @@ -224,15 +229,61 @@ public void testInvalidateApiKeysForApiKeyName() throws InterruptedException, Ex PlainActionFuture listener = new PlainActionFuture<>(); securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyName(responses.get(0).getName()), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); + verifyInvalidateResponse(1, responses, invalidateResponse); + } + + public void testGetAndInvalidateApiKeysWithExpiredAndInvalidatedApiKey() throws Exception { + List responses = createApiKeys(1, null); + Instant created = Instant.now(); + + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + + AtomicReference docId = new AtomicReference<>(); + assertBusy(() -> { + SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))).setSize(10) + .setTerminateAfter(10).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + docId.set(searchResponse.getHits().getAt(0).getId()); + }); + + // hack doc to modify the expiration time to the week before + Instant weekBefore = created.minus(8L, ChronoUnit.DAYS); + assertTrue(Instant.now().isAfter(weekBefore)); + client.prepareUpdate(SecurityIndexManager.SECURITY_INDEX_NAME, "doc", docId.get()) + .setDoc("expiration_time", weekBefore.toEpochMilli()).setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE).get(); + + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + verifyInvalidateResponse(1, responses, invalidateResponse); + + // try again + listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + invalidateResponse = listener.get(); + assertTrue(invalidateResponse.getInvalidatedApiKeys().isEmpty()); + + // Get API key though returns the API key information + PlainActionFuture listener1 = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener1); + GetApiKeyResponse response = listener1.get(); + verifyGetResponse(1, responses, response, Collections.emptySet(), Collections.singletonList(responses.get(0).getId())); + } + + private void verifyInvalidateResponse(int noOfApiKeys, List responses, + InvalidateApiKeyResponse invalidateResponse) { + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(noOfApiKeys)); assertThat(invalidateResponse.getInvalidatedApiKeys(), - equalTo(responses.stream().map(r -> r.getId()).collect(Collectors.toList()))); + containsInAnyOrder(responses.stream().map(r -> r.getId()).collect(Collectors.toList()).toArray(Strings.EMPTY_ARRAY))); assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); assertThat(invalidateResponse.getErrors().size(), equalTo(0)); } public void testInvalidatedApiKeysDeletedByRemover() throws Exception { - List responses = createApiKeys(1, null); + List responses = createApiKeys(2, null); Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); @@ -246,27 +297,27 @@ public void testInvalidatedApiKeysDeletedByRemover() throws Exception { AtomicReference docId = new AtomicReference<>(); assertBusy(() -> { SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) - .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) - .setSize(1).setTerminateAfter(1).get(); - assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))).setSize(10) + .setTerminateAfter(10).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(2L)); docId.set(searchResponse.getHits().getAt(0).getId()); }); AtomicBoolean deleteTriggered = new AtomicBoolean(false); assertBusy(() -> { if (deleteTriggered.compareAndSet(false, true)) { - securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(1).getId()), new PlainActionFuture<>()); } client.admin().indices().prepareRefresh(SecurityIndexManager.SECURITY_INDEX_NAME).get(); SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) - .setTerminateAfter(1).get(); - assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L)); + .setTerminateAfter(10).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); }, 30, TimeUnit.SECONDS); } public void testExpiredApiKeysDeletedAfter1Week() throws Exception { - createApiKeys(1, null); + List responses = createApiKeys(2, null); Instant created = Instant.now(); Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken @@ -276,9 +327,9 @@ public void testExpiredApiKeysDeletedAfter1Week() throws Exception { AtomicReference docId = new AtomicReference<>(); assertBusy(() -> { SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) - .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))).setSize(1) - .setTerminateAfter(1).get(); - assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); + .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))).setSize(10) + .setTerminateAfter(10).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(2L)); docId.set(searchResponse.getHits().getAt(0).getId()); }); @@ -291,38 +342,137 @@ public void testExpiredApiKeysDeletedAfter1Week() throws Exception { AtomicBoolean deleteTriggered = new AtomicBoolean(false); assertBusy(() -> { if (deleteTriggered.compareAndSet(false, true)) { - // just random api key invalidation so that it triggers expired keys remover - securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(6)), new PlainActionFuture<>()); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(1).getId()), new PlainActionFuture<>()); } client.admin().indices().prepareRefresh(SecurityIndexManager.SECURITY_INDEX_NAME).get(); SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) - .setTerminateAfter(1).get(); - assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L)); + .setTerminateAfter(10).get(); + assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); }, 30, TimeUnit.SECONDS); } public void testActiveApiKeysWithNoExpirationNeverGetDeletedByRemover() throws Exception { - List responses = createApiKeys(1, null); + List responses = createApiKeys(2, null); Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); SecurityClient securityClient = new SecurityClient(client); PlainActionFuture listener = new PlainActionFuture<>(); - securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(7)), listener); + // trigger expired keys remover + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(1).getId()), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); - assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(0)); + assertThat(invalidateResponse.getInvalidatedApiKeys().size(), equalTo(1)); assertThat(invalidateResponse.getPreviouslyInvalidatedApiKeys().size(), equalTo(0)); - assertThat(invalidateResponse.getErrors().size(), equalTo(1)); - AtomicReference docId = new AtomicReference<>(); - assertBusy(() -> { - SearchResponse searchResponse = client.prepareSearch(SecurityIndexManager.SECURITY_INDEX_NAME) - .setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", "api_key"))) - .setSize(1).setTerminateAfter(1).get(); - assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L)); - docId.set(searchResponse.getHits().getAt(0).getId()); - }); - assertThat(docId.get(), equalTo(responses.get(0).getId())); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + + PlainActionFuture getApiKeyResponseListener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingRealmName("file"), getApiKeyResponseListener); + GetApiKeyResponse response = getApiKeyResponseListener.get(); + verifyGetResponse(2, responses, response, Collections.singleton(responses.get(0).getId()), + Collections.singletonList(responses.get(1).getId())); + } + + public void testGetApiKeysForRealm() throws InterruptedException, ExecutionException { + int noOfApiKeys = randomIntBetween(3, 5); + List responses = createApiKeys(noOfApiKeys, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + boolean invalidate= randomBoolean(); + List invalidatedApiKeyIds = null; + Set expectedValidKeyIds = null; + if (invalidate) { + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.invalidateApiKey(InvalidateApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + InvalidateApiKeyResponse invalidateResponse = listener.get(); + invalidatedApiKeyIds = invalidateResponse.getInvalidatedApiKeys(); + expectedValidKeyIds = responses.stream().filter(o -> !o.getId().equals(responses.get(0).getId())).map(o -> o.getId()) + .collect(Collectors.toSet()); + } else { + invalidatedApiKeyIds = Collections.emptyList(); + expectedValidKeyIds = responses.stream().map(o -> o.getId()).collect(Collectors.toSet()); + } + + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingRealmName("file"), listener); + GetApiKeyResponse response = listener.get(); + verifyGetResponse(noOfApiKeys, responses, response, + expectedValidKeyIds, + invalidatedApiKeyIds); + } + + public void testGetApiKeysForUser() throws Exception { + int noOfApiKeys = randomIntBetween(3, 5); + List responses = createApiKeys(noOfApiKeys, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingUserName(SecuritySettingsSource.TEST_SUPERUSER), listener); + GetApiKeyResponse response = listener.get(); + verifyGetResponse(noOfApiKeys, responses, response, responses.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); + } + + public void testGetApiKeysForRealmAndUser() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingRealmAndUserName("file", SecuritySettingsSource.TEST_SUPERUSER), + listener); + GetApiKeyResponse response = listener.get(); + verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + } + + public void testGetApiKeysForApiKeyId() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingApiKeyId(responses.get(0).getId()), listener); + GetApiKeyResponse response = listener.get(); + verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + } + + public void testGetApiKeysForApiKeyName() throws InterruptedException, ExecutionException { + List responses = createApiKeys(1, null); + Client client = client().filterWithHeader(Collections.singletonMap("Authorization", UsernamePasswordToken + .basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + SecurityClient securityClient = new SecurityClient(client); + PlainActionFuture listener = new PlainActionFuture<>(); + securityClient.getApiKey(GetApiKeyRequest.usingApiKeyName(responses.get(0).getName()), listener); + GetApiKeyResponse response = listener.get(); + verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + } + + private void verifyGetResponse(int noOfApiKeys, List responses, GetApiKeyResponse response, + Set validApiKeyIds, + List invalidatedApiKeyIds) { + assertThat(response.getApiKeyInfos().length, equalTo(noOfApiKeys)); + List expectedIds = responses.stream().filter(o -> validApiKeyIds.contains(o.getId())).map(o -> o.getId()) + .collect(Collectors.toList()); + List actualIds = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false).map(o -> o.getId()) + .collect(Collectors.toList()); + assertThat(actualIds, containsInAnyOrder(expectedIds.toArray(Strings.EMPTY_ARRAY))); + List expectedNames = responses.stream().filter(o -> validApiKeyIds.contains(o.getId())).map(o -> o.getName()) + .collect(Collectors.toList()); + List actualNames = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false).map(o -> o.getName()) + .collect(Collectors.toList()); + assertThat(actualNames, containsInAnyOrder(expectedNames.toArray(Strings.EMPTY_ARRAY))); + Set expectedUsernames = (validApiKeyIds.isEmpty()) ? Collections.emptySet() + : Collections.singleton(SecuritySettingsSource.TEST_SUPERUSER); + Set actualUsernames = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false) + .map(o -> o.getUsername()).collect(Collectors.toSet()); + assertThat(actualUsernames, containsInAnyOrder(expectedUsernames.toArray(Strings.EMPTY_ARRAY))); + if (invalidatedApiKeyIds != null) { + List actualInvalidatedApiKeyIds = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated()) + .map(o -> o.getId()).collect(Collectors.toList()); + assertThat(invalidatedApiKeyIds, containsInAnyOrder(actualInvalidatedApiKeyIds.toArray(Strings.EMPTY_ARRAY))); + } + } private List createApiKeys(int noOfApiKeys, TimeValue expiration) { diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml index 2ce6532d8255b..61ebf98b9d815 100644 --- a/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/api_key/10_basic.yml @@ -87,24 +87,6 @@ teardown: "name": "my-api-key-1", "expiration": "1d", "role_descriptors": { - "role-a": { - "cluster": ["all"], - "index": [ - { - "names": ["index-a"], - "privileges": ["read"] - } - ] - }, - "role-b": { - "cluster": ["manage"], - "index": [ - { - "names": ["index-b"], - "privileges": ["all"] - } - ] - } } } - match: { name: "my-api-key-1" } From 00395281a0c4746521628a6e1aab5c5b23996e00 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Thu, 24 Jan 2019 07:38:19 +1100 Subject: [PATCH 19/26] API key authorization support (#37335) When creating API keys one can specify role descriptors to define permissions for API keys. If none provided then by default the permissions are limited to the authenticated principal during API key creation. With the API key, we are now storing the API key role descriptors and the role descriptors for the authenticated user. This commit adds a `LimitedRole` corresponding to the authenticated principal's role and is built from the role descriptors saved with the API key. Similar to `FieldPermissions`, introduced `DocumentPermissions` that assist in the filter query formation from the queries and limited queries. For the field permissions that exist in both limited by permissions and the API key permissions then we do the intersection of the allowed fields. The authorization service has been modified such that the limited role is also considered when checking for cluster and indices actions. The actions are only allowed when they are allowed by api key role and the authenticated principal's role. In this commit, I have not handled the Get/Has privileges API. --- .../elasticsearch/common/util/set/Sets.java | 15 + .../common/util/set/SetsTests.java | 12 + .../accesscontrol/IndicesAccessControl.java | 72 ++++- .../SecurityIndexSearcherWrapper.java | 179 +----------- .../authz/permission/DocumentPermissions.java | 262 ++++++++++++++++++ .../authz/permission/FieldPermissions.java | 39 ++- .../authz/permission/IndicesPermission.java | 3 +- .../authz/permission/LimitedRole.java | 92 ++++++ .../core/security/authz/permission/Role.java | 34 ++- .../SecurityQueryTemplateEvaluator.java | 92 ++++++ .../core/security/support/Automatons.java | 6 + .../resources/security-index-template.json | 4 + ...yIndexSearcherWrapperIntegrationTests.java | 131 ++++++++- ...SecurityIndexSearcherWrapperUnitTests.java | 152 +--------- .../permission/DocumentPermissionsTests.java | 123 ++++++++ .../permission/FieldPermissionsTests.java | 81 ++++++ .../authz/permission/LimitedRoleTests.java | 104 +++++++ .../SecurityQueryTemplateEvaluatorTests.java | 94 +++++++ .../xpack/security/Security.java | 23 +- .../action/TransportCreateApiKeyAction.java | 14 +- .../BulkShardRequestInterceptor.java | 2 +- ...cumentLevelSecurityRequestInterceptor.java | 2 +- .../IndicesAliasesRequestInterceptor.java | 2 +- .../interceptor/ResizeRequestInterceptor.java | 2 +- .../xpack/security/authc/ApiKeyService.java | 64 ++++- .../security/authz/AuthorizationService.java | 16 +- .../security/authz/AuthorizedIndices.java | 2 +- .../xpack/security/SecurityTests.java | 7 +- ...IndicesAliasesRequestInterceptorTests.java | 4 +- .../ResizeRequestInterceptorTests.java | 4 +- .../security/authc/ApiKeyServiceTests.java | 98 ++++++- .../authc/AuthenticationServiceTests.java | 4 +- .../authz/AuthorizationServiceTests.java | 25 +- .../IndicesAccessControlTests.java | 101 +++++++ .../accesscontrol/IndicesPermissionTests.java | 53 ++-- .../accesscontrol/OptOutQueryCacheTests.java | 28 +- 36 files changed, 1517 insertions(+), 429 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissions.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRole.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/support/SecurityQueryTemplateEvaluator.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissionsTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/permission/FieldPermissionsTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRoleTests.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/support/SecurityQueryTemplateEvaluatorTests.java diff --git a/server/src/main/java/org/elasticsearch/common/util/set/Sets.java b/server/src/main/java/org/elasticsearch/common/util/set/Sets.java index 0f1fe22c02010..02d534552100c 100644 --- a/server/src/main/java/org/elasticsearch/common/util/set/Sets.java +++ b/server/src/main/java/org/elasticsearch/common/util/set/Sets.java @@ -144,4 +144,19 @@ public static Set union(Set left, Set right) { union.addAll(right); return union; } + + public static Set intersection(Set set1, Set set2) { + Objects.requireNonNull(set1); + Objects.requireNonNull(set2); + final Set left; + final Set right; + if (set1.size() < set2.size()) { + left = set1; + right = set2; + } else { + left = set2; + right = set1; + } + return left.stream().filter(o -> right.contains(o)).collect(Collectors.toSet()); + } } diff --git a/server/src/test/java/org/elasticsearch/common/util/set/SetsTests.java b/server/src/test/java/org/elasticsearch/common/util/set/SetsTests.java index 0c1869a6b4086..f4337daf4346c 100644 --- a/server/src/test/java/org/elasticsearch/common/util/set/SetsTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/set/SetsTests.java @@ -28,6 +28,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; @@ -56,6 +57,17 @@ public void testSortedDifference() { } } + public void testIntersection() { + final int endExclusive = randomIntBetween(0, 256); + final Tuple, Set> sets = randomSets(endExclusive); + final Set intersection = Sets.intersection(sets.v1(), sets.v2()); + final Set expectedIntersection = IntStream.range(0, endExclusive) + .boxed() + .filter(i -> (sets.v1().contains(i) && sets.v2().contains(i))) + .collect(Collectors.toSet()); + assertThat(intersection, containsInAnyOrder(expectedIntersection.toArray(new Integer[0]))); + } + /** * Assert the difference between two sets is as expected. * diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/IndicesAccessControl.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/IndicesAccessControl.java index 6df9ad834c1e5..8cdf099e676d8 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/IndicesAccessControl.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/IndicesAccessControl.java @@ -6,11 +6,13 @@ package org.elasticsearch.xpack.core.security.authz.accesscontrol; import org.elasticsearch.common.Nullable; -import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; +import org.elasticsearch.xpack.core.security.authz.permission.DocumentPermissions; import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -22,7 +24,7 @@ public class IndicesAccessControl { public static final IndicesAccessControl ALLOW_ALL = new IndicesAccessControl(true, Collections.emptyMap()); public static final IndicesAccessControl ALLOW_NO_INDICES = new IndicesAccessControl(true, Collections.singletonMap(IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER, - new IndicesAccessControl.IndexAccessControl(true, new FieldPermissions(), null))); + new IndicesAccessControl.IndexAccessControl(true, new FieldPermissions(), DocumentPermissions.allowAll()))); private final boolean granted; private final Map indexPermissions; @@ -55,12 +57,12 @@ public static class IndexAccessControl { private final boolean granted; private final FieldPermissions fieldPermissions; - private final Set queries; + private final DocumentPermissions documentPermissions; - public IndexAccessControl(boolean granted, FieldPermissions fieldPermissions, Set queries) { + public IndexAccessControl(boolean granted, FieldPermissions fieldPermissions, DocumentPermissions documentPermissions) { this.granted = granted; - this.fieldPermissions = fieldPermissions; - this.queries = queries; + this.fieldPermissions = (fieldPermissions == null) ? FieldPermissions.DEFAULT : fieldPermissions; + this.documentPermissions = (documentPermissions == null) ? DocumentPermissions.allowAll() : documentPermissions; } /** @@ -82,8 +84,33 @@ public FieldPermissions getFieldPermissions() { * then this means that there are no document level restrictions */ @Nullable - public Set getQueries() { - return queries; + public DocumentPermissions getDocumentPermissions() { + return documentPermissions; + } + + /** + * Returns a instance of {@link IndexAccessControl}, where the privileges for {@code this} object are constrained by the privileges + * contained in the provided parameter.
+ * Allowed fields for this index permission would be an intersection of allowed fields.
+ * Allowed documents for this index permission would be an intersection of allowed documents.
+ * + * @param limitedByIndexAccessControl {@link IndexAccessControl} + * @return {@link IndexAccessControl} + * @see FieldPermissions#limitFieldPermissions(FieldPermissions) + * @see DocumentPermissions#limitDocumentPermissions(DocumentPermissions) + */ + public IndexAccessControl limitIndexAccessControl(IndexAccessControl limitedByIndexAccessControl) { + final boolean granted; + if (this.granted == limitedByIndexAccessControl.granted) { + granted = this.granted; + } else { + granted = false; + } + FieldPermissions fieldPermissions = getFieldPermissions().limitFieldPermissions( + limitedByIndexAccessControl.fieldPermissions); + DocumentPermissions documentPermissions = getDocumentPermissions() + .limitDocumentPermissions(limitedByIndexAccessControl.getDocumentPermissions()); + return new IndexAccessControl(granted, fieldPermissions, documentPermissions); } @Override @@ -91,11 +118,38 @@ public String toString() { return "IndexAccessControl{" + "granted=" + granted + ", fieldPermissions=" + fieldPermissions + - ", queries=" + queries + + ", documentPermissions=" + documentPermissions + '}'; } } + /** + * Returns a instance of {@link IndicesAccessControl}, where the privileges for {@code this} + * object are constrained by the privileges contained in the provided parameter.
+ * + * @param limitedByIndicesAccessControl {@link IndicesAccessControl} + * @return {@link IndicesAccessControl} + */ + public IndicesAccessControl limitIndicesAccessControl(IndicesAccessControl limitedByIndicesAccessControl) { + final boolean granted; + if (this.granted == limitedByIndicesAccessControl.granted) { + granted = this.granted; + } else { + granted = false; + } + Set indexes = indexPermissions.keySet(); + Set otherIndexes = limitedByIndicesAccessControl.indexPermissions.keySet(); + Set commonIndexes = Sets.intersection(indexes, otherIndexes); + + Map indexPermissions = new HashMap<>(commonIndexes.size()); + for (String index : commonIndexes) { + IndexAccessControl indexAccessControl = getIndexPermissions(index); + IndexAccessControl limitedByIndexAccessControl = limitedByIndicesAccessControl.getIndexPermissions(index); + indexPermissions.put(index, indexAccessControl.limitIndexAccessControl(limitedByIndexAccessControl)); + } + return new IndicesAccessControl(granted, indexPermissions); + } + @Override public String toString() { return "IndicesAccessControl{" + diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapper.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapper.java index a8651701448d2..56383909d846e 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapper.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapper.java @@ -5,8 +5,8 @@ */ package org.elasticsearch.xpack.core.security.authz.accesscontrol; -import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.LeafReaderContext; import org.apache.lucene.search.BooleanQuery; @@ -18,64 +18,35 @@ import org.apache.lucene.search.DocIdSetIterator; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.LeafCollector; -import org.apache.lucene.search.Query; import org.apache.lucene.search.Scorer; import org.apache.lucene.search.Weight; -import org.apache.lucene.search.join.BitSetProducer; -import org.apache.lucene.search.join.ToChildBlockJoinQuery; import org.apache.lucene.util.BitSet; import org.apache.lucene.util.BitSetIterator; import org.apache.lucene.util.Bits; import org.apache.lucene.util.SparseFixedBitSet; -import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.ExceptionsHelper; -import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.logging.LoggerMessageFormat; -import org.elasticsearch.common.lucene.search.Queries; import org.elasticsearch.common.util.concurrent.ThreadContext; -import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; -import org.elasticsearch.common.xcontent.NamedXContentRegistry; -import org.elasticsearch.common.xcontent.XContentFactory; -import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.index.cache.bitset.BitsetFilterCache; import org.elasticsearch.index.engine.EngineException; -import org.elasticsearch.index.query.BoolQueryBuilder; -import org.elasticsearch.index.query.BoostingQueryBuilder; -import org.elasticsearch.index.query.ConstantScoreQueryBuilder; -import org.elasticsearch.index.query.GeoShapeQueryBuilder; -import org.elasticsearch.index.query.QueryBuilder; -import org.elasticsearch.index.query.QueryRewriteContext; import org.elasticsearch.index.query.QueryShardContext; -import org.elasticsearch.index.query.Rewriteable; -import org.elasticsearch.index.query.TermsQueryBuilder; -import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder; -import org.elasticsearch.index.search.NestedHelper; import org.elasticsearch.index.shard.IndexSearcherWrapper; import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.index.shard.ShardUtils; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.script.Script; import org.elasticsearch.script.ScriptService; -import org.elasticsearch.script.ScriptType; -import org.elasticsearch.script.TemplateScript; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetReader.DocumentSubsetDirectoryReader; +import org.elasticsearch.xpack.core.security.authz.permission.DocumentPermissions; import org.elasticsearch.xpack.core.security.support.Exceptions; import org.elasticsearch.xpack.core.security.user.User; import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.function.Function; -import static org.apache.lucene.search.BooleanClause.Occur.FILTER; -import static org.apache.lucene.search.BooleanClause.Occur.SHOULD; - /** * An {@link IndexSearcherWrapper} implementation that is used for field and document level security. *

@@ -107,7 +78,7 @@ public SecurityIndexSearcherWrapper(Function querySh } @Override - protected DirectoryReader wrap(DirectoryReader reader) { + protected DirectoryReader wrap(final DirectoryReader reader) { if (licenseState.isDocumentAndFieldLevelSecurityAllowed() == false) { return reader; } @@ -120,47 +91,22 @@ protected DirectoryReader wrap(DirectoryReader reader) { throw new IllegalStateException(LoggerMessageFormat.format("couldn't extract shardId from reader [{}]", reader)); } - IndicesAccessControl.IndexAccessControl permissions = indicesAccessControl.getIndexPermissions(shardId.getIndexName()); + final IndicesAccessControl.IndexAccessControl permissions = indicesAccessControl.getIndexPermissions(shardId.getIndexName()); // No permissions have been defined for an index, so don't intercept the index reader for access control if (permissions == null) { return reader; } - if (permissions.getQueries() != null) { - BooleanQuery.Builder filter = new BooleanQuery.Builder(); - for (BytesReference bytesReference : permissions.getQueries()) { - QueryShardContext queryShardContext = queryShardContextProvider.apply(shardId); - String templateResult = evaluateTemplate(bytesReference.utf8ToString()); - try (XContentParser parser = XContentFactory.xContent(templateResult) - .createParser(queryShardContext.getXContentRegistry(), LoggingDeprecationHandler.INSTANCE, templateResult)) { - QueryBuilder queryBuilder = queryShardContext.parseInnerQueryBuilder(parser); - verifyRoleQuery(queryBuilder); - failIfQueryUsesClient(queryBuilder, queryShardContext); - Query roleQuery = queryShardContext.toQuery(queryBuilder).query(); - filter.add(roleQuery, SHOULD); - if (queryShardContext.getMapperService().hasNested()) { - NestedHelper nestedHelper = new NestedHelper(queryShardContext.getMapperService()); - if (nestedHelper.mightMatchNestedDocs(roleQuery)) { - roleQuery = new BooleanQuery.Builder() - .add(roleQuery, FILTER) - .add(Queries.newNonNestedFilter(queryShardContext.indexVersionCreated()), FILTER) - .build(); - } - // If access is allowed on root doc then also access is allowed on all nested docs of that root document: - BitSetProducer rootDocs = queryShardContext.bitsetFilter( - Queries.newNonNestedFilter(queryShardContext.indexVersionCreated())); - ToChildBlockJoinQuery includeNestedDocs = new ToChildBlockJoinQuery(roleQuery, rootDocs); - filter.add(includeNestedDocs, SHOULD); - } - } + DirectoryReader wrappedReader = reader; + DocumentPermissions documentPermissions = permissions.getDocumentPermissions(); + if (documentPermissions != null && documentPermissions.hasDocumentLevelPermissions()) { + BooleanQuery filterQuery = documentPermissions.filter(getUser(), scriptService, shardId, queryShardContextProvider); + if (filterQuery != null) { + wrappedReader = DocumentSubsetReader.wrap(wrappedReader, bitsetFilterCache, new ConstantScoreQuery(filterQuery)); } - - // at least one of the queries should match - filter.setMinimumNumberShouldMatch(1); - reader = DocumentSubsetReader.wrap(reader, bitsetFilterCache, new ConstantScoreQuery(filter.build())); } - return permissions.getFieldPermissions().filter(reader); + return permissions.getFieldPermissions().filter(wrappedReader); } catch (IOException e) { logger.error("Unable to apply field level security"); throw ExceptionsHelper.convertToElastic(e); @@ -255,48 +201,6 @@ static void intersectScorerAndRoleBits(Scorer scorer, SparseFixedBitSet roleBits } } - String evaluateTemplate(String querySource) throws IOException { - // EMPTY is safe here because we never use namedObject - try (XContentParser parser = XContentFactory.xContent(querySource).createParser(NamedXContentRegistry.EMPTY, - LoggingDeprecationHandler.INSTANCE, querySource)) { - XContentParser.Token token = parser.nextToken(); - if (token != XContentParser.Token.START_OBJECT) { - throw new ElasticsearchParseException("Unexpected token [" + token + "]"); - } - token = parser.nextToken(); - if (token != XContentParser.Token.FIELD_NAME) { - throw new ElasticsearchParseException("Unexpected token [" + token + "]"); - } - if ("template".equals(parser.currentName())) { - token = parser.nextToken(); - if (token != XContentParser.Token.START_OBJECT) { - throw new ElasticsearchParseException("Unexpected token [" + token + "]"); - } - Script script = Script.parse(parser); - // Add the user details to the params - Map params = new HashMap<>(); - if (script.getParams() != null) { - params.putAll(script.getParams()); - } - User user = getUser(); - Map userModel = new HashMap<>(); - userModel.put("username", user.principal()); - userModel.put("full_name", user.fullName()); - userModel.put("email", user.email()); - userModel.put("roles", Arrays.asList(user.roles())); - userModel.put("metadata", Collections.unmodifiableMap(user.metadata())); - params.put("_user", userModel); - // Always enforce mustache script lang: - script = new Script(script.getType(), - script.getType() == ScriptType.STORED ? null : "mustache", script.getIdOrCode(), script.getOptions(), params); - TemplateScript compiledTemplate = scriptService.compile(script, TemplateScript.CONTEXT).newInstance(script.getParams()); - return compiledTemplate.execute(); - } else { - return querySource; - } - } - } - protected IndicesAccessControl getIndicesAccessControl() { IndicesAccessControl indicesAccessControl = threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY); if (indicesAccessControl == null) { @@ -310,65 +214,4 @@ protected User getUser(){ return authentication.getUser(); } - /** - * Checks whether the role query contains queries we know can't be used as DLS role query. - */ - static void verifyRoleQuery(QueryBuilder queryBuilder) throws IOException { - if (queryBuilder instanceof TermsQueryBuilder) { - TermsQueryBuilder termsQueryBuilder = (TermsQueryBuilder) queryBuilder; - if (termsQueryBuilder.termsLookup() != null) { - throw new IllegalArgumentException("terms query with terms lookup isn't supported as part of a role query"); - } - } else if (queryBuilder instanceof GeoShapeQueryBuilder) { - GeoShapeQueryBuilder geoShapeQueryBuilder = (GeoShapeQueryBuilder) queryBuilder; - if (geoShapeQueryBuilder.shape() == null) { - throw new IllegalArgumentException("geoshape query referring to indexed shapes isn't support as part of a role query"); - } - } else if (queryBuilder.getName().equals("percolate")) { - // actually only if percolate query is referring to an existing document then this is problematic, - // a normal percolate query does work. However we can't check that here as this query builder is inside - // another module. So we don't allow the entire percolate query. I don't think users would ever use - // a percolate query as role query, so this restriction shouldn't prohibit anyone from using dls. - throw new IllegalArgumentException("percolate query isn't support as part of a role query"); - } else if (queryBuilder.getName().equals("has_child")) { - throw new IllegalArgumentException("has_child query isn't support as part of a role query"); - } else if (queryBuilder.getName().equals("has_parent")) { - throw new IllegalArgumentException("has_parent query isn't support as part of a role query"); - } else if (queryBuilder instanceof BoolQueryBuilder) { - BoolQueryBuilder boolQueryBuilder = (BoolQueryBuilder) queryBuilder; - List clauses = new ArrayList<>(); - clauses.addAll(boolQueryBuilder.filter()); - clauses.addAll(boolQueryBuilder.must()); - clauses.addAll(boolQueryBuilder.mustNot()); - clauses.addAll(boolQueryBuilder.should()); - for (QueryBuilder clause : clauses) { - verifyRoleQuery(clause); - } - } else if (queryBuilder instanceof ConstantScoreQueryBuilder) { - verifyRoleQuery(((ConstantScoreQueryBuilder) queryBuilder).innerQuery()); - } else if (queryBuilder instanceof FunctionScoreQueryBuilder) { - verifyRoleQuery(((FunctionScoreQueryBuilder) queryBuilder).query()); - } else if (queryBuilder instanceof BoostingQueryBuilder) { - verifyRoleQuery(((BoostingQueryBuilder) queryBuilder).negativeQuery()); - verifyRoleQuery(((BoostingQueryBuilder) queryBuilder).positiveQuery()); - } - } - - /** - * Fall back validation that verifies that queries during rewrite don't use - * the client to make remote calls. In the case of DLS this can cause a dead - * lock if DLS is also applied on these remote calls. For example in the - * case of terms query with lookup, this can cause recursive execution of - * the DLS query until the get thread pool has been exhausted: - * https://github.com/elastic/x-plugins/issues/3145 - */ - static void failIfQueryUsesClient(QueryBuilder queryBuilder, QueryRewriteContext original) - throws IOException { - QueryRewriteContext copy = new QueryRewriteContext( - original.getXContentRegistry(), original.getWriteableRegistry(), null, original::nowInMillis); - Rewriteable.rewrite(queryBuilder, copy); - if (copy.hasAsyncActions()) { - throw new IllegalStateException("role queries are not allowed to execute additional requests"); - } - } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissions.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissions.java new file mode 100644 index 0000000000000..08d754b4e5357 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissions.java @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.authz.permission; + +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.join.BitSetProducer; +import org.apache.lucene.search.join.ToChildBlockJoinQuery; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.lucene.search.Queries; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.BoostingQueryBuilder; +import org.elasticsearch.index.query.ConstantScoreQueryBuilder; +import org.elasticsearch.index.query.GeoShapeQueryBuilder; +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.index.query.QueryRewriteContext; +import org.elasticsearch.index.query.QueryShardContext; +import org.elasticsearch.index.query.Rewriteable; +import org.elasticsearch.index.query.TermsQueryBuilder; +import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder; +import org.elasticsearch.index.search.NestedHelper; +import org.elasticsearch.index.shard.ShardId; +import org.elasticsearch.script.ScriptService; +import org.elasticsearch.xpack.core.security.authz.support.SecurityQueryTemplateEvaluator; +import org.elasticsearch.xpack.core.security.user.User; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import static org.apache.lucene.search.BooleanClause.Occur.FILTER; +import static org.apache.lucene.search.BooleanClause.Occur.SHOULD; + +/** + * Stores document level permissions in the form queries that match all the accessible documents.
+ * The document level permissions may be limited by another set of queries in that case the limited + * queries are used as an additional filter. + */ +public final class DocumentPermissions { + private final Set queries; + private final Set limitedByQueries; + + private static DocumentPermissions ALLOW_ALL = new DocumentPermissions(); + + DocumentPermissions() { + this.queries = null; + this.limitedByQueries = null; + } + + DocumentPermissions(Set queries) { + this(queries, null); + } + + DocumentPermissions(Set queries, Set scopedByQueries) { + if (queries == null && scopedByQueries == null) { + throw new IllegalArgumentException("one of the queries or scoped queries must be provided"); + } + this.queries = (queries != null) ? Collections.unmodifiableSet(queries) : queries; + this.limitedByQueries = (scopedByQueries != null) ? Collections.unmodifiableSet(scopedByQueries) : scopedByQueries; + } + + public Set getQueries() { + return queries; + } + + public Set getLimitedByQueries() { + return limitedByQueries; + } + + /** + * @return {@code true} if either queries or scoped queries are present for document level security else returns {@code false} + */ + public boolean hasDocumentLevelPermissions() { + return queries != null || limitedByQueries != null; + } + + /** + * Creates a {@link BooleanQuery} to be used as filter to restrict access to documents.
+ * Document permission queries are used to create an boolean query.
+ * If the document permissions are limited, then there is an additional filter added restricting access to documents only allowed by the + * limited queries. + * + * @param user authenticated {@link User} + * @param scriptService {@link ScriptService} for evaluating query templates + * @param shardId {@link ShardId} + * @param queryShardContextProvider {@link QueryShardContext} + * @return {@link BooleanQuery} for the filter + * @throws IOException thrown if there is an exception during parsing + */ + public BooleanQuery filter(User user, ScriptService scriptService, ShardId shardId, + Function queryShardContextProvider) throws IOException { + if (hasDocumentLevelPermissions()) { + BooleanQuery.Builder filter; + if (queries != null && limitedByQueries != null) { + filter = new BooleanQuery.Builder(); + BooleanQuery.Builder scopedFilter = new BooleanQuery.Builder(); + buildRoleQuery(user, scriptService, shardId, queryShardContextProvider, limitedByQueries, scopedFilter); + filter.add(scopedFilter.build(), FILTER); + + buildRoleQuery(user, scriptService, shardId, queryShardContextProvider, queries, filter); + } else if (queries != null) { + filter = new BooleanQuery.Builder(); + buildRoleQuery(user, scriptService, shardId, queryShardContextProvider, queries, filter); + } else if (limitedByQueries != null) { + filter = new BooleanQuery.Builder(); + buildRoleQuery(user, scriptService, shardId, queryShardContextProvider, limitedByQueries, filter); + } else { + return null; + } + return filter.build(); + } + return null; + } + + private static void buildRoleQuery(User user, ScriptService scriptService, ShardId shardId, + Function queryShardContextProvider, Set queries, + BooleanQuery.Builder filter) throws IOException { + for (BytesReference bytesReference : queries) { + QueryShardContext queryShardContext = queryShardContextProvider.apply(shardId); + String templateResult = SecurityQueryTemplateEvaluator.evaluateTemplate(bytesReference.utf8ToString(), scriptService, user); + try (XContentParser parser = XContentFactory.xContent(templateResult).createParser(queryShardContext.getXContentRegistry(), + LoggingDeprecationHandler.INSTANCE, templateResult)) { + QueryBuilder queryBuilder = queryShardContext.parseInnerQueryBuilder(parser); + verifyRoleQuery(queryBuilder); + failIfQueryUsesClient(queryBuilder, queryShardContext); + Query roleQuery = queryShardContext.toQuery(queryBuilder).query(); + filter.add(roleQuery, SHOULD); + if (queryShardContext.getMapperService().hasNested()) { + NestedHelper nestedHelper = new NestedHelper(queryShardContext.getMapperService()); + if (nestedHelper.mightMatchNestedDocs(roleQuery)) { + roleQuery = new BooleanQuery.Builder().add(roleQuery, FILTER) + .add(Queries.newNonNestedFilter(queryShardContext.indexVersionCreated()), FILTER).build(); + } + // If access is allowed on root doc then also access is allowed on all nested docs of that root document: + BitSetProducer rootDocs = queryShardContext + .bitsetFilter(Queries.newNonNestedFilter(queryShardContext.indexVersionCreated())); + ToChildBlockJoinQuery includeNestedDocs = new ToChildBlockJoinQuery(roleQuery, rootDocs); + filter.add(includeNestedDocs, SHOULD); + } + } + } + // at least one of the queries should match + filter.setMinimumNumberShouldMatch(1); + } + + /** + * Checks whether the role query contains queries we know can't be used as DLS role query. + */ + static void verifyRoleQuery(QueryBuilder queryBuilder) throws IOException { + if (queryBuilder instanceof TermsQueryBuilder) { + TermsQueryBuilder termsQueryBuilder = (TermsQueryBuilder) queryBuilder; + if (termsQueryBuilder.termsLookup() != null) { + throw new IllegalArgumentException("terms query with terms lookup isn't supported as part of a role query"); + } + } else if (queryBuilder instanceof GeoShapeQueryBuilder) { + GeoShapeQueryBuilder geoShapeQueryBuilder = (GeoShapeQueryBuilder) queryBuilder; + if (geoShapeQueryBuilder.shape() == null) { + throw new IllegalArgumentException("geoshape query referring to indexed shapes isn't support as part of a role query"); + } + } else if (queryBuilder.getName().equals("percolate")) { + // actually only if percolate query is referring to an existing document then this is problematic, + // a normal percolate query does work. However we can't check that here as this query builder is inside + // another module. So we don't allow the entire percolate query. I don't think users would ever use + // a percolate query as role query, so this restriction shouldn't prohibit anyone from using dls. + throw new IllegalArgumentException("percolate query isn't support as part of a role query"); + } else if (queryBuilder.getName().equals("has_child")) { + throw new IllegalArgumentException("has_child query isn't support as part of a role query"); + } else if (queryBuilder.getName().equals("has_parent")) { + throw new IllegalArgumentException("has_parent query isn't support as part of a role query"); + } else if (queryBuilder instanceof BoolQueryBuilder) { + BoolQueryBuilder boolQueryBuilder = (BoolQueryBuilder) queryBuilder; + List clauses = new ArrayList<>(); + clauses.addAll(boolQueryBuilder.filter()); + clauses.addAll(boolQueryBuilder.must()); + clauses.addAll(boolQueryBuilder.mustNot()); + clauses.addAll(boolQueryBuilder.should()); + for (QueryBuilder clause : clauses) { + verifyRoleQuery(clause); + } + } else if (queryBuilder instanceof ConstantScoreQueryBuilder) { + verifyRoleQuery(((ConstantScoreQueryBuilder) queryBuilder).innerQuery()); + } else if (queryBuilder instanceof FunctionScoreQueryBuilder) { + verifyRoleQuery(((FunctionScoreQueryBuilder) queryBuilder).query()); + } else if (queryBuilder instanceof BoostingQueryBuilder) { + verifyRoleQuery(((BoostingQueryBuilder) queryBuilder).negativeQuery()); + verifyRoleQuery(((BoostingQueryBuilder) queryBuilder).positiveQuery()); + } + } + + /** + * Fall back validation that verifies that queries during rewrite don't use + * the client to make remote calls. In the case of DLS this can cause a dead + * lock if DLS is also applied on these remote calls. For example in the + * case of terms query with lookup, this can cause recursive execution of + * the DLS query until the get thread pool has been exhausted: + * https://github.com/elastic/x-plugins/issues/3145 + */ + static void failIfQueryUsesClient(QueryBuilder queryBuilder, QueryRewriteContext original) + throws IOException { + QueryRewriteContext copy = new QueryRewriteContext( + original.getXContentRegistry(), original.getWriteableRegistry(), null, original::nowInMillis); + Rewriteable.rewrite(queryBuilder, copy); + if (copy.hasAsyncActions()) { + throw new IllegalStateException("role queries are not allowed to execute additional requests"); + } + } + + /** + * Create {@link DocumentPermissions} for given set of queries + * @param queries set of queries + * @return {@link DocumentPermissions} + */ + public static DocumentPermissions filteredBy(Set queries) { + if (queries == null || queries.isEmpty()) { + throw new IllegalArgumentException("null or empty queries not permitted"); + } + return new DocumentPermissions(queries); + } + + /** + * Create {@link DocumentPermissions} with no restriction. The {@link #getQueries()} + * will return {@code null} in this case and {@link #hasDocumentLevelPermissions()} + * will be {@code false} + * @return {@link DocumentPermissions} + */ + public static DocumentPermissions allowAll() { + return ALLOW_ALL; + } + + /** + * Create a document permissions, where the permissions for {@code this} are + * limited by the queries from other document permissions.
+ * + * @param limitedByDocumentPermissions {@link DocumentPermissions} used to limit the document level access + * @return instance of {@link DocumentPermissions} + */ + public DocumentPermissions limitDocumentPermissions( + DocumentPermissions limitedByDocumentPermissions) { + assert limitedByQueries == null + && limitedByDocumentPermissions.limitedByQueries == null : "nested scoping for document permissions is not permitted"; + if (queries == null && limitedByDocumentPermissions.queries == null) { + return DocumentPermissions.allowAll(); + } + return new DocumentPermissions(queries, limitedByDocumentPermissions.queries); + } + + @Override + public String toString() { + return "DocumentPermissions [queries=" + queries + ", scopedByQueries=" + limitedByQueries + "]"; + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/FieldPermissions.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/FieldPermissions.java index 7e45b893fed6b..f58367dc43886 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/FieldPermissions.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/FieldPermissions.java @@ -90,13 +90,15 @@ public FieldPermissions(FieldPermissionsDefinition fieldPermissionsDefinition) { long ramBytesUsed = BASE_FIELD_PERM_DEF_BYTES; - for (FieldGrantExcludeGroup group : fieldPermissionsDefinition.getFieldGrantExcludeGroups()) { - ramBytesUsed += BASE_FIELD_GROUP_BYTES + BASE_HASHSET_ENTRY_SIZE; - if (group.getGrantedFields() != null) { - ramBytesUsed += RamUsageEstimator.shallowSizeOf(group.getGrantedFields()); - } - if (group.getExcludedFields() != null) { - ramBytesUsed += RamUsageEstimator.shallowSizeOf(group.getExcludedFields()); + if (fieldPermissionsDefinition != null) { + for (FieldGrantExcludeGroup group : fieldPermissionsDefinition.getFieldGrantExcludeGroups()) { + ramBytesUsed += BASE_FIELD_GROUP_BYTES + BASE_HASHSET_ENTRY_SIZE; + if (group.getGrantedFields() != null) { + ramBytesUsed += RamUsageEstimator.shallowSizeOf(group.getGrantedFields()); + } + if (group.getExcludedFields() != null) { + ramBytesUsed += RamUsageEstimator.shallowSizeOf(group.getExcludedFields()); + } } } ramBytesUsed += permittedFieldsAutomaton.ramBytesUsed(); @@ -153,6 +155,28 @@ private static Automaton initializePermittedFieldsAutomaton(final String[] grant return grantedFieldsAutomaton; } + /** + * Returns a field permissions instance where it is limited by the given field permissions.
+ * If the current and the other field permissions have field level security then it takes + * an intersection of permitted fields.
+ * If none of the permissions have field level security enabled, then returns permissions + * instance where all fields are allowed. + * + * @param limitedBy {@link FieldPermissions} used to limit current field permissions + * @return {@link FieldPermissions} + */ + public FieldPermissions limitFieldPermissions(FieldPermissions limitedBy) { + if (hasFieldLevelSecurity() && limitedBy != null && limitedBy.hasFieldLevelSecurity()) { + Automaton permittedFieldsAutomaton = Automatons.intersectAndMinimize(getIncludeAutomaton(), limitedBy.getIncludeAutomaton()); + return new FieldPermissions(null, permittedFieldsAutomaton); + } else if (limitedBy != null && limitedBy.hasFieldLevelSecurity()) { + return new FieldPermissions(limitedBy.getFieldPermissionsDefinition(), limitedBy.getIncludeAutomaton()); + } else if (hasFieldLevelSecurity()) { + return new FieldPermissions(getFieldPermissionsDefinition(), getIncludeAutomaton()); + } + return FieldPermissions.DEFAULT; + } + /** * Returns true if this field permission policy allows access to the field and false if not. * fieldName can be a wildcard. @@ -178,7 +202,6 @@ public DirectoryReader filter(DirectoryReader reader) throws IOException { return FieldSubsetReader.wrap(reader, permittedFieldsAutomaton); } - // for testing only Automaton getIncludeAutomaton() { return originalAutomaton; } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/IndicesPermission.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/IndicesPermission.java index 4c2a479721a2a..0f6fb59d7cd83 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/IndicesPermission.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/IndicesPermission.java @@ -207,7 +207,8 @@ public Map authorize(String act } else { fieldPermissions = FieldPermissions.DEFAULT; } - indexPermissions.put(index, new IndicesAccessControl.IndexAccessControl(entry.getValue(), fieldPermissions, roleQueries)); + indexPermissions.put(index, new IndicesAccessControl.IndexAccessControl(entry.getValue(), fieldPermissions, + (roleQueries != null) ? DocumentPermissions.filteredBy(roleQueries) : DocumentPermissions.allowAll())); } return unmodifiableMap(indexPermissions); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRole.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRole.java new file mode 100644 index 0000000000000..cfe40c4d85004 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRole.java @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.authz.permission; + +import org.elasticsearch.cluster.metadata.MetaData; +import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** + * A {@link Role} limited by another role.
+ * The effective permissions returned on {@link #authorize(String, Set, MetaData, FieldPermissionsCache)} call would be limited by the + * provided role. + */ +public final class LimitedRole extends Role { + private final Role limitedBy; + + LimitedRole(String[] names, ClusterPermission cluster, IndicesPermission indices, ApplicationPermission application, + RunAsPermission runAs, Role limitedBy) { + super(names, cluster, indices, application, runAs); + assert limitedBy != null : "limiting role is required"; + this.limitedBy = limitedBy; + } + + public Role limitedBy() { + return limitedBy; + } + + @Override + public IndicesAccessControl authorize(String action, Set requestedIndicesOrAliases, MetaData metaData, + FieldPermissionsCache fieldPermissionsCache) { + IndicesAccessControl indicesAccessControl = super.authorize(action, requestedIndicesOrAliases, metaData, fieldPermissionsCache); + IndicesAccessControl limitedByIndicesAccessControl = limitedBy.authorize(action, requestedIndicesOrAliases, metaData, + fieldPermissionsCache); + + return indicesAccessControl.limitIndicesAccessControl(limitedByIndicesAccessControl); + } + + /** + * @return A predicate that will match all the indices that this role and + * the scoped by role has the privilege for executing the given action on. + */ + public Predicate allowedIndicesMatcher(String action) { + Predicate predicate = indices().allowedIndicesMatcher(action); + predicate = predicate.and(limitedBy.indices().allowedIndicesMatcher(action)); + return predicate; + } + + /** + * Check if indices permissions allow for the given action, + * also checks whether the scoped by role allows the given actions + * + * @param action indices action + * @return {@code true} if action is allowed else returns {@code false} + */ + @Override + public boolean checkIndicesAction(String action) { + return super.checkIndicesAction(action) && limitedBy.checkIndicesAction(action); + } + + /** + * Check if cluster permissions allow for the given action, + * also checks whether the scoped by role allows the given actions + * + * @param action cluster action + * @param request {@link TransportRequest} + * @return {@code true} if action is allowed else returns {@code false} + */ + public boolean checkClusterAction(String action, TransportRequest request) { + return super.checkClusterAction(action, request) && limitedBy.checkClusterAction(action, request); + } + + /** + * Create a new role defined by given role and the limited role. + * + * @param fromRole existing role {@link Role} + * @param limitedByRole restrict the newly formed role to the permissions defined by this limited {@link Role} + * @return {@link LimitedRole} + */ + public static LimitedRole createLimitedRole(Role fromRole, Role limitedByRole) { + Objects.requireNonNull(limitedByRole, "limited by role is required to create limited role"); + return new LimitedRole(fromRole.names(), fromRole.cluster(), fromRole.indices(), fromRole.application(), fromRole.runAs(), + limitedByRole); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java index 1f789e96d5a04..2aadb39acaf85 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java @@ -10,6 +10,7 @@ import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.collect.Tuple; import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.transport.TransportRequest; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl; import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilege; @@ -25,8 +26,9 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Predicate; -public final class Role { +public class Role { public static final Role EMPTY = Role.builder("__empty").build(); @@ -44,6 +46,7 @@ public final class Role { this.runAs = Objects.requireNonNull(runAs); } + public String[] names() { return names; } @@ -72,6 +75,35 @@ public static Builder builder(RoleDescriptor rd, FieldPermissionsCache fieldPerm return new Builder(rd, fieldPermissionsCache); } + /** + * @return A predicate that will match all the indices that this role + * has the privilege for executing the given action on. + */ + public Predicate allowedIndicesMatcher(String action) { + return indices().allowedIndicesMatcher(action); + } + + /** + * Check if indices permissions allow for the given action + * + * @param action indices action + * @return {@code true} if action is allowed else returns {@code false} + */ + public boolean checkIndicesAction(String action) { + return indices().check(action); + } + + /** + * Check if cluster permissions allow for the given action + * + * @param action cluster action + * @param request {@link TransportRequest} + * @return {@code true} if action is allowed else returns {@code false} + */ + public boolean checkClusterAction(String action, TransportRequest request) { + return cluster().check(action, request); + } + /** * Returns whether at least one group encapsulated by this indices permissions is authorized to execute the * specified action with the requested indices/aliases. At the same time if field and/or document level security diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/support/SecurityQueryTemplateEvaluator.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/support/SecurityQueryTemplateEvaluator.java new file mode 100644 index 0000000000000..951c4acf10d0d --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/support/SecurityQueryTemplateEvaluator.java @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.authz.support; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.script.Script; +import org.elasticsearch.script.ScriptService; +import org.elasticsearch.script.ScriptType; +import org.elasticsearch.script.TemplateScript; +import org.elasticsearch.xpack.core.security.user.User; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Helper class that helps to evaluate the query source template. + */ +public final class SecurityQueryTemplateEvaluator { + + private SecurityQueryTemplateEvaluator() { + } + + /** + * If the query source is a template, then parses the script, compiles the + * script with user details parameters and then executes it to return the + * query string. + *

+ * Note: This method always enforces "mustache" script language for the + * template. + * + * @param querySource query string template to be evaluated. + * @param scriptService {@link ScriptService} + * @param user {@link User} details for user defined parameters in the + * script. + * @return resultant query string after compiling and executing the script. + * If the source does not contain template then it will return the query + * source without any modifications. + * @throws IOException thrown when there is any error parsing the query + * string. + */ + public static String evaluateTemplate(final String querySource, final ScriptService scriptService, final User user) throws IOException { + // EMPTY is safe here because we never use namedObject + try (XContentParser parser = XContentFactory.xContent(querySource).createParser(NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, querySource)) { + XContentParser.Token token = parser.nextToken(); + if (token != XContentParser.Token.START_OBJECT) { + throw new ElasticsearchParseException("Unexpected token [" + token + "]"); + } + token = parser.nextToken(); + if (token != XContentParser.Token.FIELD_NAME) { + throw new ElasticsearchParseException("Unexpected token [" + token + "]"); + } + if ("template".equals(parser.currentName())) { + token = parser.nextToken(); + if (token != XContentParser.Token.START_OBJECT) { + throw new ElasticsearchParseException("Unexpected token [" + token + "]"); + } + Script script = Script.parse(parser); + // Add the user details to the params + Map params = new HashMap<>(); + if (script.getParams() != null) { + params.putAll(script.getParams()); + } + Map userModel = new HashMap<>(); + userModel.put("username", user.principal()); + userModel.put("full_name", user.fullName()); + userModel.put("email", user.email()); + userModel.put("roles", Arrays.asList(user.roles())); + userModel.put("metadata", Collections.unmodifiableMap(user.metadata())); + params.put("_user", userModel); + // Always enforce mustache script lang: + script = new Script(script.getType(), script.getType() == ScriptType.STORED ? null : "mustache", script.getIdOrCode(), + script.getOptions(), params); + TemplateScript compiledTemplate = scriptService.compile(script, TemplateScript.CONTEXT).newInstance(script.getParams()); + return compiledTemplate.execute(); + } else { + return querySource; + } + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/support/Automatons.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/support/Automatons.java index 87a0099580b5f..7e6fd7ca46283 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/support/Automatons.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/support/Automatons.java @@ -26,6 +26,7 @@ import static org.apache.lucene.util.automaton.MinimizationOperations.minimize; import static org.apache.lucene.util.automaton.Operations.DEFAULT_MAX_DETERMINIZED_STATES; import static org.apache.lucene.util.automaton.Operations.concatenate; +import static org.apache.lucene.util.automaton.Operations.intersection; import static org.apache.lucene.util.automaton.Operations.minus; import static org.apache.lucene.util.automaton.Operations.union; import static org.elasticsearch.common.Strings.collectionToDelimitedString; @@ -173,6 +174,11 @@ public static Automaton minusAndMinimize(Automaton a1, Automaton a2) { return minimize(res, maxDeterminizedStates); } + public static Automaton intersectAndMinimize(Automaton a1, Automaton a2) { + Automaton res = intersection(a1, a2); + return minimize(res, maxDeterminizedStates); + } + public static Predicate predicate(String... patterns) { return predicate(Arrays.asList(patterns)); } diff --git a/x-pack/plugin/core/src/main/resources/security-index-template.json b/x-pack/plugin/core/src/main/resources/security-index-template.json index 32b230658c6ce..183ffff4ea534 100644 --- a/x-pack/plugin/core/src/main/resources/security-index-template.json +++ b/x-pack/plugin/core/src/main/resources/security-index-template.json @@ -164,6 +164,10 @@ "type" : "object", "enabled": false }, + "limited_by_role_descriptors" : { + "type" : "object", + "enabled": false + }, "version" : { "type" : "integer" }, diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperIntegrationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperIntegrationTests.java index ff132894af8ed..5eccd4090e8bf 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperIntegrationTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperIntegrationTests.java @@ -8,6 +8,7 @@ import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; +import org.apache.lucene.document.Field.Store; import org.apache.lucene.document.StringField; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.IndexWriter; @@ -16,12 +17,14 @@ import org.apache.lucene.index.Term; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.TotalHitCountCollector; import org.apache.lucene.store.Directory; import org.apache.lucene.util.Accountable; import org.elasticsearch.client.Client; import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.lucene.index.ElasticsearchDirectoryReader; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -36,14 +39,21 @@ import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.script.ScriptService; -import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.AbstractBuilderTestCase; import org.elasticsearch.test.IndexSettingsModule; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationField; +import org.elasticsearch.xpack.core.security.authz.permission.DocumentPermissions; import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions; +import org.elasticsearch.xpack.core.security.user.User; import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import static java.util.Collections.singleton; import static java.util.Collections.singletonMap; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; @@ -52,7 +62,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; -public class SecurityIndexSearcherWrapperIntegrationTests extends ESTestCase { +public class SecurityIndexSearcherWrapperIntegrationTests extends AbstractBuilderTestCase { public void testDLS() throws Exception { ShardId shardId = new ShardId("_index", "_na_", 0); @@ -63,9 +73,12 @@ public void testDLS() throws Exception { .then(invocationOnMock -> Collections.singletonList((String) invocationOnMock.getArguments()[0])); ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + final Authentication authentication = mock(Authentication.class); + when(authentication.getUser()).thenReturn(mock(User.class)); + threadContext.putTransient(AuthenticationField.AUTHENTICATION_KEY, authentication); IndicesAccessControl.IndexAccessControl indexAccessControl = new IndicesAccessControl.IndexAccessControl(true, new FieldPermissions(), - singleton(new BytesArray("{\"match_all\" : {}}"))); + DocumentPermissions.filteredBy(singleton(new BytesArray("{\"match_all\" : {}}")))); IndexSettings indexSettings = IndexSettingsModule.newIndexSettings(shardId.getIndex(), Settings.EMPTY); Client client = mock(Client.class); when(client.settings()).thenReturn(Settings.EMPTY); @@ -158,4 +171,116 @@ protected IndicesAccessControl getIndicesAccessControl() { directoryReader.close(); directory.close(); } + + public void testDLSWithLimitedPermissions() throws Exception { + ShardId shardId = new ShardId("_index", "_na_", 0); + MapperService mapperService = mock(MapperService.class); + ScriptService scriptService = mock(ScriptService.class); + when(mapperService.documentMapper()).thenReturn(null); + when(mapperService.simpleMatchToFullName(anyString())) + .then(invocationOnMock -> Collections.singletonList((String) invocationOnMock.getArguments()[0])); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + final Authentication authentication = mock(Authentication.class); + when(authentication.getUser()).thenReturn(mock(User.class)); + threadContext.putTransient(AuthenticationField.AUTHENTICATION_KEY, authentication); + final boolean noFilteredIndexPermissions = randomBoolean(); + boolean restrictiveLimitedIndexPermissions = false; + if (noFilteredIndexPermissions == false) { + restrictiveLimitedIndexPermissions = randomBoolean(); + } + Set queries = new HashSet<>(); + queries.add(new BytesArray("{\"terms\" : { \"f2\" : [\"fv22\"] } }")); + queries.add(new BytesArray("{\"terms\" : { \"f2\" : [\"fv32\"] } }")); + IndicesAccessControl.IndexAccessControl indexAccessControl = new IndicesAccessControl.IndexAccessControl(true, new + FieldPermissions(), + DocumentPermissions.filteredBy(queries)); + queries = singleton(new BytesArray("{\"terms\" : { \"f1\" : [\"fv11\", \"fv21\", \"fv31\"] } }")); + if (restrictiveLimitedIndexPermissions) { + queries = singleton(new BytesArray("{\"terms\" : { \"f1\" : [\"fv11\", \"fv31\"] } }")); + } + IndicesAccessControl.IndexAccessControl limitedIndexAccessControl = new IndicesAccessControl.IndexAccessControl(true, new + FieldPermissions(), + DocumentPermissions.filteredBy(queries)); + IndexSettings indexSettings = IndexSettingsModule.newIndexSettings(shardId.getIndex(), Settings.EMPTY); + Client client = mock(Client.class); + when(client.settings()).thenReturn(Settings.EMPTY); + final long nowInMillis = randomNonNegativeLong(); + QueryShardContext realQueryShardContext = new QueryShardContext(shardId.id(), indexSettings, null, null, mapperService, null, + null, xContentRegistry(), writableRegistry(), client, null, () -> nowInMillis, null); + QueryShardContext queryShardContext = spy(realQueryShardContext); + IndexSettings settings = IndexSettingsModule.newIndexSettings("_index", Settings.EMPTY); + BitsetFilterCache bitsetFilterCache = new BitsetFilterCache(settings, new BitsetFilterCache.Listener() { + @Override + public void onCache(ShardId shardId, Accountable accountable) { + } + + @Override + public void onRemoval(ShardId shardId, Accountable accountable) { + } + }); + + XPackLicenseState licenseState = mock(XPackLicenseState.class); + when(licenseState.isDocumentAndFieldLevelSecurityAllowed()).thenReturn(true); + SecurityIndexSearcherWrapper wrapper = new SecurityIndexSearcherWrapper(s -> queryShardContext, + bitsetFilterCache, threadContext, licenseState, scriptService) { + + @Override + protected IndicesAccessControl getIndicesAccessControl() { + IndicesAccessControl indicesAccessControl = new IndicesAccessControl(true, singletonMap("_index", indexAccessControl)); + if (noFilteredIndexPermissions) { + return indicesAccessControl; + } + IndicesAccessControl limitedByIndicesAccessControl = new IndicesAccessControl(true, + singletonMap("_index", limitedIndexAccessControl)); + return indicesAccessControl.limitIndicesAccessControl(limitedByIndicesAccessControl); + } + }; + + Directory directory = newDirectory(); + IndexWriter iw = new IndexWriter( + directory, + new IndexWriterConfig(new StandardAnalyzer()).setMergePolicy(NoMergePolicy.INSTANCE) + ); + + Document doc1 = new Document(); + doc1.add(new StringField("f1", "fv11", Store.NO)); + doc1.add(new StringField("f2", "fv12", Store.NO)); + iw.addDocument(doc1); + Document doc2 = new Document(); + doc2.add(new StringField("f1", "fv21", Store.NO)); + doc2.add(new StringField("f2", "fv22", Store.NO)); + iw.addDocument(doc2); + Document doc3 = new Document(); + doc3.add(new StringField("f1", "fv31", Store.NO)); + doc3.add(new StringField("f2", "fv32", Store.NO)); + iw.addDocument(doc3); + iw.commit(); + iw.close(); + + DirectoryReader directoryReader = ElasticsearchDirectoryReader.wrap(DirectoryReader.open(directory), shardId); + DirectoryReader wrappedDirectoryReader = wrapper.wrap(directoryReader); + IndexSearcher indexSearcher = wrapper.wrap(new IndexSearcher(wrappedDirectoryReader)); + + ScoreDoc[] hits = indexSearcher.search(new MatchAllDocsQuery(), 1000).scoreDocs; + Set actualDocIds = new HashSet<>(); + for (ScoreDoc doc : hits) { + actualDocIds.add(doc.doc); + } + + if (noFilteredIndexPermissions) { + assertThat(actualDocIds, containsInAnyOrder(1, 2)); + } else { + if (restrictiveLimitedIndexPermissions) { + assertThat(actualDocIds, containsInAnyOrder(2)); + } else { + assertThat(actualDocIds, containsInAnyOrder(1, 2)); + } + } + + bitsetFilterCache.close(); + directoryReader.close(); + directory.close(); + } + } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperUnitTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperUnitTests.java index 06838ac6ffae1..7900eaba4c848 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperUnitTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/accesscontrol/SecurityIndexSearcherWrapperUnitTests.java @@ -28,21 +28,16 @@ import org.apache.lucene.search.Scorer; import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.Weight; -import org.apache.lucene.search.join.ScoreMode; import org.apache.lucene.store.Directory; -import org.apache.lucene.store.RAMDirectory; +import org.apache.lucene.store.MMapDirectory; import org.apache.lucene.util.Accountable; import org.apache.lucene.util.BitSet; import org.apache.lucene.util.FixedBitSet; -import org.elasticsearch.core.internal.io.IOUtils; import org.apache.lucene.util.SparseFixedBitSet; -import org.elasticsearch.client.Client; -import org.elasticsearch.common.Strings; import org.elasticsearch.common.lucene.index.ElasticsearchDirectoryReader; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; -import org.elasticsearch.common.xcontent.ToXContent; -import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.core.internal.io.IOUtils; import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexSettings; import org.elasticsearch.index.cache.bitset.BitsetFilterCache; @@ -50,59 +45,35 @@ import org.elasticsearch.index.mapper.MapperService; import org.elasticsearch.index.mapper.SeqNoFieldMapper; import org.elasticsearch.index.mapper.SourceFieldMapper; -import org.elasticsearch.index.query.BoolQueryBuilder; -import org.elasticsearch.index.query.BoostingQueryBuilder; -import org.elasticsearch.index.query.ConstantScoreQueryBuilder; -import org.elasticsearch.index.query.GeoShapeQueryBuilder; -import org.elasticsearch.index.query.MatchAllQueryBuilder; -import org.elasticsearch.index.query.QueryBuilder; -import org.elasticsearch.index.query.QueryRewriteContext; -import org.elasticsearch.index.query.TermQueryBuilder; -import org.elasticsearch.index.query.TermsQueryBuilder; -import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder; import org.elasticsearch.index.shard.IndexShard; import org.elasticsearch.index.shard.ShardId; -import org.elasticsearch.indices.TermsLookup; -import org.elasticsearch.join.query.HasChildQueryBuilder; -import org.elasticsearch.join.query.HasParentQueryBuilder; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.script.Script; import org.elasticsearch.script.ScriptService; -import org.elasticsearch.script.ScriptType; -import org.elasticsearch.script.TemplateScript; import org.elasticsearch.search.aggregations.LeafBucketCollector; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.IndexSettingsModule; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetReader.DocumentSubsetDirectoryReader; +import org.elasticsearch.xpack.core.security.authz.permission.DocumentPermissions; import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions; import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsDefinition; -import org.elasticsearch.xpack.core.security.user.User; import org.junit.After; import org.junit.Before; -import org.mockito.ArgumentCaptor; import java.io.IOException; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; -import java.util.Map; import java.util.Set; import static java.util.Collections.singletonMap; -import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; import static org.elasticsearch.xpack.core.security.authz.accesscontrol.SecurityIndexSearcherWrapper.intersectScorerAndRoleBits; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.sameInstance; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; public class SecurityIndexSearcherWrapperUnitTests extends ESTestCase { @@ -136,7 +107,7 @@ public void setup() throws Exception { IndexShard indexShard = mock(IndexShard.class); when(indexShard.shardId()).thenReturn(shardId); - Directory directory = new RAMDirectory(); + Directory directory = new MMapDirectory(createTempDir()); IndexWriter writer = new IndexWriter(directory, newIndexWriterConfig()); writer.close(); @@ -156,7 +127,7 @@ public void testDefaultMetaFields() throws Exception { @Override protected IndicesAccessControl getIndicesAccessControl() { IndicesAccessControl.IndexAccessControl indexAccessControl = new IndicesAccessControl.IndexAccessControl(true, - new FieldPermissions(fieldPermissionDef(new String[]{}, null)), null); + new FieldPermissions(fieldPermissionDef(new String[]{}, null)), DocumentPermissions.allowAll()); return new IndicesAccessControl(true, singletonMap("_index", indexAccessControl)); } }; @@ -423,66 +394,6 @@ public void testIndexSearcherWrapperDenseWithDeletions() throws IOException { doTestIndexSearcherWrapper(false, true); } - public void testTemplating() throws Exception { - User user = new User("_username", new String[]{"role1", "role2"}, "_full_name", "_email", - Collections.singletonMap("key", "value"), true); - securityIndexSearcherWrapper = - new SecurityIndexSearcherWrapper(null, null, threadContext, licenseState, scriptService) { - - @Override - protected User getUser() { - return user; - } - }; - - TemplateScript.Factory compiledTemplate = templateParams -> - new TemplateScript(templateParams) { - @Override - public String execute() { - return "rendered_text"; - } - }; - - when(scriptService.compile(any(Script.class), eq(TemplateScript.CONTEXT))).thenReturn(compiledTemplate); - - XContentBuilder builder = jsonBuilder(); - String query = Strings.toString(new TermQueryBuilder("field", "{{_user.username}}").toXContent(builder, ToXContent.EMPTY_PARAMS)); - Script script = new Script(ScriptType.INLINE, "mustache", query, Collections.singletonMap("custom", "value")); - builder = jsonBuilder().startObject().field("template"); - script.toXContent(builder, ToXContent.EMPTY_PARAMS); - String querySource = Strings.toString(builder.endObject()); - - securityIndexSearcherWrapper.evaluateTemplate(querySource); - ArgumentCaptor