diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseBulkUpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseBulkUpdateApiKeyRequest.java new file mode 100644 index 0000000000000..43b83fb9c0d31 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseBulkUpdateApiKeyRequest.java @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public abstract class BaseBulkUpdateApiKeyRequest extends BaseUpdateApiKeyRequest { + + private final List ids; + + public BaseBulkUpdateApiKeyRequest( + final List ids, + @Nullable final List roleDescriptors, + @Nullable final Map metadata + ) { + super(roleDescriptors, metadata); + this.ids = Objects.requireNonNull(ids, "API key IDs must not be null"); + } + + public BaseBulkUpdateApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.ids = in.readStringList(); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = super.validate(); + if (ids.isEmpty()) { + validationException = addValidationError("Field [ids] cannot be empty", validationException); + } + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeStringCollection(ids); + } + + public List getIds() { + return ids; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseUpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseUpdateApiKeyRequest.java index 696893f0f41ef..c4f42107e0279 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseUpdateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseUpdateApiKeyRequest.java @@ -48,6 +48,8 @@ public List getRoleDescriptors() { return roleDescriptors; } + public abstract ApiKey.Type getType(); + @Override public ActionRequestValidationException validate() { ActionRequestValidationException validationException = null; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java index 59461265d6a5b..d4712abd2cfe2 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java @@ -7,9 +7,7 @@ package org.elasticsearch.xpack.core.security.action.apikey; -import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.common.io.stream.StreamInput; -import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.core.Nullable; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -17,11 +15,8 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; -import static org.elasticsearch.action.ValidateActions.addValidationError; - -public final class BulkUpdateApiKeyRequest extends BaseUpdateApiKeyRequest { +public final class BulkUpdateApiKeyRequest extends BaseBulkUpdateApiKeyRequest { public static BulkUpdateApiKeyRequest usingApiKeyIds(String... ids) { return new BulkUpdateApiKeyRequest(Arrays.stream(ids).toList(), null, null); @@ -31,38 +26,20 @@ public static BulkUpdateApiKeyRequest wrap(final UpdateApiKeyRequest request) { return new BulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata()); } - private final List ids; - public BulkUpdateApiKeyRequest( final List ids, @Nullable final List roleDescriptors, @Nullable final Map metadata ) { - super(roleDescriptors, metadata); - this.ids = Objects.requireNonNull(ids, "API key IDs must not be null"); + super(ids, roleDescriptors, metadata); } public BulkUpdateApiKeyRequest(StreamInput in) throws IOException { super(in); - this.ids = in.readStringList(); } @Override - public ActionRequestValidationException validate() { - ActionRequestValidationException validationException = super.validate(); - if (ids.isEmpty()) { - validationException = addValidationError("Field [ids] cannot be empty", validationException); - } - return validationException; - } - - @Override - public void writeTo(StreamOutput out) throws IOException { - super.writeTo(out); - out.writeStringCollection(ids); - } - - public List getIds() { - return ids; + public ApiKey.Type getType() { + return ApiKey.Type.REST; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequest.java index c864b9528dd49..2b993155b4fc4 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequest.java @@ -14,8 +14,6 @@ import org.elasticsearch.core.Assertions; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; -import org.elasticsearch.xcontent.XContentParserConfiguration; -import org.elasticsearch.xcontent.json.JsonXContent; import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -99,14 +97,6 @@ public int hashCode() { } public static CreateCrossClusterApiKeyRequest withNameAndAccess(String name, String access) throws IOException { - return new CreateCrossClusterApiKeyRequest( - name, - CrossClusterApiKeyRoleDescriptorBuilder.PARSER.parse( - JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, access), - null - ), - null, - null - ); + return new CreateCrossClusterApiKeyRequest(name, CrossClusterApiKeyRoleDescriptorBuilder.parse(access), null, null); } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java index 13a5aa141579c..92c12763143d7 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java @@ -11,8 +11,11 @@ import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.xcontent.ConstructingObjectParser; import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.json.JsonXContent; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -87,6 +90,13 @@ public RoleDescriptor build() { ); } + public static CrossClusterApiKeyRoleDescriptorBuilder parse(String access) throws IOException { + return CrossClusterApiKeyRoleDescriptorBuilder.PARSER.parse( + JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, access), + null + ); + } + static void validate(RoleDescriptor roleDescriptor) { if (false == ROLE_DESCRIPTOR_NAME.equals(roleDescriptor.getName())) { throw new IllegalArgumentException("invalid role descriptor name [" + roleDescriptor.getName() + "]"); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java index 688349dca3cf3..f5d707f9ce5a8 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java @@ -47,4 +47,9 @@ public void writeTo(StreamOutput out) throws IOException { public String getId() { return id; } + + @Override + public ApiKey.Type getType() { + return ApiKey.Type.REST; + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..9cfda77b3b5f1 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyAction.java @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionType; + +public final class UpdateCrossClusterApiKeyAction extends ActionType { + + public static final String NAME = "cluster:admin/xpack/security/cross_cluster/api_key/update"; + public static final UpdateCrossClusterApiKeyAction INSTANCE = new UpdateCrossClusterApiKeyAction(); + + private UpdateCrossClusterApiKeyAction() { + super(NAME, UpdateApiKeyResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequest.java new file mode 100644 index 0000000000000..dc1a6a6d5fe9c --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequest.java @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public final class UpdateCrossClusterApiKeyRequest extends BaseUpdateApiKeyRequest { + private final String id; + + public UpdateCrossClusterApiKeyRequest( + final String id, + @Nullable CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder, + @Nullable final Map metadata + ) { + super(roleDescriptorBuilder == null ? null : List.of(roleDescriptorBuilder.build()), metadata); + this.id = Objects.requireNonNull(id, "API key ID must not be null"); + } + + public UpdateCrossClusterApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.id = in.readString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(id); + } + + public String getId() { + return id; + } + + @Override + public ApiKey.Type getType() { + return ApiKey.Type.CROSS_CLUSTER; + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = super.validate(); + if (roleDescriptors == null && metadata == null) { + validationException = addValidationError( + "must update either [access] or [metadata] for cross-cluster API keys", + validationException + ); + } + return validationException; + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequestTests.java index eb2e4b4a8a300..79a5bb9379ef8 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequestTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequestTests.java @@ -11,50 +11,21 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.Tuple; import org.elasticsearch.test.AbstractWireSerializingTestCase; -import org.elasticsearch.xcontent.XContentParser; -import org.elasticsearch.xcontent.XContentParserConfiguration; import org.junit.Before; import java.io.IOException; -import java.io.UncheckedIOException; import java.util.List; import java.util.Map; -import static org.elasticsearch.xcontent.json.JsonXContent.jsonXContent; - public class CreateCrossClusterApiKeyRequestTests extends AbstractWireSerializingTestCase { - private static final List ACCESS_CANDIDATES = List.of(""" - { - "search": [ {"names": ["logs"]} ] - }""", """ - { - "search": [ {"names": ["logs"], "query": "abc" } ] - }""", """ - { - "search": [ {"names": ["logs"], "field_security": {"grant": ["*"], "except": ["private"]} } ] - }""", """ - { - "search": [ {"names": ["logs"], "query": "abc", "field_security": {"grant": ["*"], "except": ["private"]} } ] - }""", """ - { - "replication": [ {"names": ["archive"], "allow_restricted_indices": true } ] - }""", """ - { - "replication": [ {"names": ["archive"]} ] - }""", """ - { - "search": [ {"names": ["logs"]} ], - "replication": [ {"names": ["archive"]} ] - }"""); - private String access; private CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder; @Before - public void init() { - access = randomFrom(ACCESS_CANDIDATES); - roleDescriptorBuilder = parseForCrossClusterApiKeyRoleDescriptorBuilder(access); + public void init() throws IOException { + access = randomCrossClusterApiKeyAccessField(); + roleDescriptorBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(access); } @Override @@ -86,7 +57,9 @@ protected CreateCrossClusterApiKeyRequest mutateInstance(CreateCrossClusterApiKe case 2 -> { return new CreateCrossClusterApiKeyRequest( instance.getName(), - parseForCrossClusterApiKeyRoleDescriptorBuilder(randomValueOtherThan(access, () -> randomFrom(ACCESS_CANDIDATES))), + CrossClusterApiKeyRoleDescriptorBuilder.parse( + randomValueOtherThan(access, CreateCrossClusterApiKeyRequestTests::randomCrossClusterApiKeyAccessField) + ), instance.getExpiration(), instance.getMetadata() ); @@ -110,15 +83,6 @@ protected CreateCrossClusterApiKeyRequest mutateInstance(CreateCrossClusterApiKe } } - private CrossClusterApiKeyRoleDescriptorBuilder parseForCrossClusterApiKeyRoleDescriptorBuilder(String access) { - try { - final XContentParser parser = jsonXContent.createParser(XContentParserConfiguration.EMPTY, access); - return CrossClusterApiKeyRoleDescriptorBuilder.PARSER.parse(parser, null); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - private static TimeValue randomExpiration() { return randomFrom(TimeValue.timeValueHours(randomIntBetween(1, 999)), null); } @@ -136,4 +100,32 @@ private static Map randomMetadata() { null ); } + + private static final List ACCESS_CANDIDATES = List.of(""" + { + "search": [ {"names": ["logs"]} ] + }""", """ + { + "search": [ {"names": ["logs"], "query": "abc" } ] + }""", """ + { + "search": [ {"names": ["logs"], "field_security": {"grant": ["*"], "except": ["private"]} } ] + }""", """ + { + "search": [ {"names": ["logs"], "query": "abc", "field_security": {"grant": ["*"], "except": ["private"]} } ] + }""", """ + { + "replication": [ {"names": ["archive"], "allow_restricted_indices": true } ] + }""", """ + { + "replication": [ {"names": ["archive"]} ] + }""", """ + { + "search": [ {"names": ["logs"]} ], + "replication": [ {"names": ["archive"]} ] + }"""); + + public static String randomCrossClusterApiKeyAccessField() { + return randomFrom(ACCESS_CANDIDATES); + } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java index 65ff6efce6054..b5761f1e80e2d 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java @@ -21,6 +21,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsStringIgnoringCase; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; public class UpdateApiKeyRequestTests extends ESTestCase { @@ -45,6 +46,7 @@ public void testSerialization() throws IOException { final var id = randomAlphaOfLength(10); final var metadata = ApiKeyTests.randomMetadata(); final var request = new UpdateApiKeyRequest(id, descriptorList, metadata); + assertThat(request.getType(), is(ApiKey.Type.REST)); try (BytesStreamOutput out = new BytesStreamOutput()) { request.writeTo(out); @@ -52,7 +54,8 @@ public void testSerialization() throws IOException { final var serialized = new UpdateApiKeyRequest(in); assertEquals(id, serialized.getId()); assertEquals(descriptorList, serialized.getRoleDescriptors()); - assertEquals(metadata, request.getMetadata()); + assertEquals(metadata, serialized.getMetadata()); + assertEquals(request.getType(), serialized.getType()); } } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequestTests.java new file mode 100644 index 0000000000000..89a6f5b650b5a --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequestTests.java @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.util.Map; + +import static org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequestTests.randomCrossClusterApiKeyAccessField; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class UpdateCrossClusterApiKeyRequestTests extends ESTestCase { + + public void testSerialization() throws IOException { + final var metadata = ApiKeyTests.randomMetadata(); + + final CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder; + if (metadata == null || randomBoolean()) { + roleDescriptorBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(randomCrossClusterApiKeyAccessField()); + } else { + roleDescriptorBuilder = null; + } + + final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), roleDescriptorBuilder, metadata); + assertThat(request.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(request.validate(), nullValue()); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + request.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + final var serialized = new UpdateCrossClusterApiKeyRequest(in); + assertEquals(request.getId(), serialized.getId()); + assertEquals(request.getRoleDescriptors(), serialized.getRoleDescriptors()); + assertEquals(metadata, serialized.getMetadata()); + assertEquals(request.getType(), serialized.getType()); + } + } + } + + public void testNotEmptyUpdateValidation() { + final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, null); + final ActionRequestValidationException ve = request.validate(); + assertThat(ve, notNullValue()); + assertThat(ve.validationErrors(), contains("must update either [access] or [metadata] for cross-cluster API keys")); + } + + public void testMetadataKeyValidation() { + final var reservedKey = "_" + randomAlphaOfLengthBetween(0, 10); + final var metadataValue = randomAlphaOfLengthBetween(1, 10); + final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, Map.of(reservedKey, metadataValue)); + final ActionRequestValidationException ve = request.validate(); + assertThat(ve, notNullValue()); + assertThat(ve.validationErrors(), contains("API key metadata keys may not start with [_]")); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolverTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolverTests.java index 4cbbe3042258c..65434ff042628 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolverTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolverTests.java @@ -9,6 +9,8 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; import java.util.ArrayList; @@ -66,4 +68,30 @@ public void testDataStreamActionsNotGrantedByAllClusterPrivilege() { is(false) ); } + + public void testPrivilegesForCreateAndUpdateCrossClusterApiKey() { + assertThat( + ClusterPrivilegeResolver.MANAGE_API_KEY.permission() + .check(CreateCrossClusterApiKeyAction.NAME, mock(TransportRequest.class), AuthenticationTestHelper.builder().build()), + is(false) + ); + + assertThat( + ClusterPrivilegeResolver.MANAGE_API_KEY.permission() + .check(UpdateCrossClusterApiKeyAction.NAME, mock(TransportRequest.class), AuthenticationTestHelper.builder().build()), + is(false) + ); + + assertThat( + ClusterPrivilegeResolver.MANAGE_SECURITY.permission() + .check(CreateCrossClusterApiKeyAction.NAME, mock(TransportRequest.class), AuthenticationTestHelper.builder().build()), + is(true) + ); + + assertThat( + ClusterPrivilegeResolver.MANAGE_SECURITY.permission() + .check(UpdateCrossClusterApiKeyAction.NAME, mock(TransportRequest.class), AuthenticationTestHelper.builder().build()), + is(true) + ); + } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java index 6cd18e7935d84..22e6a6f005919 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java @@ -13,6 +13,8 @@ import org.elasticsearch.transport.TransportRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyRequest; @@ -20,6 +22,8 @@ import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; import org.elasticsearch.xpack.core.security.authc.AuthenticationTests; @@ -28,7 +32,9 @@ import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission; import org.elasticsearch.xpack.core.security.user.User; +import java.io.IOException; import java.util.List; +import java.util.Map; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.mock; @@ -289,4 +295,28 @@ public void testCheckGrantApiKeyRequestDenied() { assertFalse(clusterPermission.check(GrantApiKeyAction.NAME, grantApiKeyRequest, AuthenticationTestHelper.builder().build())); } + + public void testCheckCreateCrossClusterApiKeyRequestDenied() throws IOException { + final ClusterPermission clusterPermission = ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder()) + .build(); + final CreateCrossClusterApiKeyRequest request = CreateCrossClusterApiKeyRequest.withNameAndAccess( + randomAlphaOfLengthBetween(3, 8), + """ + { + "search": [ {"names": ["logs"]} ] + }""" + ); + assertFalse(clusterPermission.check(CreateCrossClusterApiKeyAction.NAME, request, AuthenticationTestHelper.builder().build())); + } + + public void testCheckUpdateCrossClusterApiKeyRequestDenied() { + final ClusterPermission clusterPermission = ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder()) + .build(); + final UpdateCrossClusterApiKeyRequest request = new UpdateCrossClusterApiKeyRequest( + randomAlphaOfLengthBetween(4, 7), + null, + Map.of() + ); + assertFalse(clusterPermission.check(UpdateCrossClusterApiKeyAction.NAME, request, AuthenticationTestHelper.builder().build())); + } } diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java index 7d0b51a0ecd1d..97da791651bd6 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java @@ -11,6 +11,9 @@ import org.elasticsearch.TransportVersion; import org.elasticsearch.Version; import org.elasticsearch.action.admin.cluster.remote.RemoteClusterNodesAction; +import org.elasticsearch.action.fieldcaps.FieldCapabilitiesAction; +import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest; +import org.elasticsearch.action.fieldcaps.FieldCapabilitiesResponse; import org.elasticsearch.client.Request; import org.elasticsearch.client.Response; import org.elasticsearch.client.internal.Client; @@ -35,7 +38,12 @@ import org.elasticsearch.xpack.ccr.action.repositories.GetCcrRestoreFileChunkRequest; import org.elasticsearch.xpack.ccr.action.repositories.PutCcrRestoreSessionAction; import org.elasticsearch.xpack.ccr.action.repositories.PutCcrRestoreSessionRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.CrossClusterAccessSubjectInfo; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; import org.elasticsearch.xpack.core.security.user.CrossClusterAccessUser; +import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.authc.CrossClusterAccessHeaders; import org.junit.ClassRule; @@ -47,6 +55,8 @@ import static org.elasticsearch.xpack.remotecluster.AbstractRemoteClusterSecurityTestCase.PASS; import static org.elasticsearch.xpack.remotecluster.AbstractRemoteClusterSecurityTestCase.USER; import static org.elasticsearch.xpack.remotecluster.AbstractRemoteClusterSecurityTestCase.createCrossClusterAccessApiKey; +import static org.elasticsearch.xpack.remotecluster.AbstractRemoteClusterSecurityTestCase.performRequestWithAdminUser; +import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -280,7 +290,134 @@ public void testRestApiKeyIsNotAllowedOnRemoteClusterPort() throws IOException { } } + public void testUpdateCrossClusterApiKey() throws IOException { + final Map crossClusterApiKeyMap = createCrossClusterAccessApiKey(adminClient(), """ + { + "search": [ + { + "names": ["other-index"] + } + ] + }"""); + final String apiKeyId = (String) crossClusterApiKeyMap.get("id"); + + // Create indices on the leader cluster + final Request bulkRequest = new Request("POST", "/_bulk?refresh=true"); + bulkRequest.setJsonEntity(Strings.format(""" + { "index": { "_index": "index" } } + { "name": "doc-1" } + """)); + assertOK(adminClient().performRequest(bulkRequest)); + + // End user subjectInfo + final CrossClusterAccessSubjectInfo crossClusterAccessSubjectInfo = new CrossClusterAccessSubjectInfo( + Authentication.newRealmAuthentication( + new User("foo", "role"), + new Authentication.RealmRef(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8), "node") + ), + new RoleDescriptorsIntersection( + new RoleDescriptor( + "cross_cluster", + null, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder().indices("index").privileges("read", "read_cross_cluster").build() }, + null + ) + ) + ); + // Field cap request to test + final FieldCapabilitiesRequest request = new FieldCapabilitiesRequest().indices("index").fields("name"); + + // Perform cross-cluster requests + try ( + MockTransportService service = startTransport( + "node", + threadPool, + (String) crossClusterApiKeyMap.get("encoded"), + Map.of(FieldCapabilitiesAction.NAME, crossClusterAccessSubjectInfo) + ) + ) { + final RemoteClusterService remoteClusterService = service.getRemoteClusterService(); + final List remoteConnectionInfos = remoteClusterService.getRemoteConnectionInfos().toList(); + assertThat(remoteConnectionInfos, hasSize(1)); + assertThat(remoteConnectionInfos.get(0).isConnected(), is(true)); + final Client remoteClusterClient = remoteClusterService.getRemoteClusterClient(threadPool, "my_remote_cluster"); + + // 1. Not accessible because API key does not grant the access + final ElasticsearchSecurityException e1 = expectThrows( + ElasticsearchSecurityException.class, + () -> remoteClusterClient.execute(FieldCapabilitiesAction.INSTANCE, request).actionGet() + ); + assertThat( + e1.getMessage(), + containsString( + "action [indices:data/read/field_caps] towards remote cluster is unauthorized " + + "for user [foo] with assigned roles [role] authenticated by API key id [" + + apiKeyId + + "] of user [test_user] on indices [index], this action is granted by the index privileges " + + "[view_index_metadata,manage,read,all]" + ) + ); + + // 2. Update the API key to grant access + final Request updateApiKeyRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateApiKeyRequest.setJsonEntity(""" + { + "access": { + "search": [ + { + "names": ["index"] + } + ] + } + }"""); + assertOK(performRequestWithAdminUser(adminClient(), updateApiKeyRequest)); + final FieldCapabilitiesResponse fieldCapabilitiesResponse = remoteClusterClient.execute( + FieldCapabilitiesAction.INSTANCE, + request + ).actionGet(); + assertThat(fieldCapabilitiesResponse.getIndices(), arrayContaining("index")); + + // 3. Update the API key again to remove access + updateApiKeyRequest.setJsonEntity(""" + { + "access": { + "replication": [ + { + "names": ["index"] + } + ] + }, + "metadata": { "tag": 42 } + }"""); + assertOK(performRequestWithAdminUser(adminClient(), updateApiKeyRequest)); + final ElasticsearchSecurityException e2 = expectThrows( + ElasticsearchSecurityException.class, + () -> remoteClusterClient.execute(FieldCapabilitiesAction.INSTANCE, request).actionGet() + ); + assertThat( + e2.getMessage(), + containsString( + "action [indices:data/read/field_caps] towards remote cluster is unauthorized " + + "for user [foo] with assigned roles [role] authenticated by API key id [" + + apiKeyId + + "] of user [test_user] on indices [index], this action is granted by the index privileges " + + "[view_index_metadata,manage,read,all]" + ) + ); + } + } + private static MockTransportService startTransport(final String nodeName, final ThreadPool threadPool, String encodedApiKey) { + return startTransport(nodeName, threadPool, encodedApiKey, Map.of()); + } + + private static MockTransportService startTransport( + final String nodeName, + final ThreadPool threadPool, + String encodedApiKey, + Map subjectInfoLookup + ) { final String remoteClusterServerEndpoint = testCluster.getRemoteClusterServerEndpoint(0); final Settings.Builder builder = Settings.builder() @@ -312,7 +449,7 @@ private static MockTransportService startTransport(final String nodeName, final try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { new CrossClusterAccessHeaders( "ApiKey " + encodedApiKey, - CrossClusterAccessUser.subjectInfo(TransportVersion.CURRENT, nodeName) + subjectInfoLookup.getOrDefault(action, CrossClusterAccessUser.subjectInfo(TransportVersion.CURRENT, nodeName)) ).writeToContext(threadContext); connection.sendRequest(requestId, action, request, options); } diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 37879a113a416..dd60cabb63901 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -7,11 +7,16 @@ package org.elasticsearch.xpack.security.operator; +import org.elasticsearch.transport.TcpTransport; + +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class Constants { - public static final Set NON_OPERATOR_ACTIONS = Set.of( + public static final Set NON_OPERATOR_ACTIONS = Stream.of( // "cluster:admin/autoscaling/delete_autoscaling_policy", "cluster:admin/autoscaling/get_autoscaling_capacity", "cluster:admin/autoscaling/get_autoscaling_policy", @@ -194,7 +199,8 @@ public class Constants { "cluster:admin/xpack/security/api_key/update", "cluster:admin/xpack/security/api_key/bulk_update", "cluster:admin/xpack/security/cache/clear", - "cluster:admin/xpack/security/cross_cluster/api_key/create", + TcpTransport.isUntrustedRemoteClusterEnabled() ? "cluster:admin/xpack/security/cross_cluster/api_key/create" : null, + TcpTransport.isUntrustedRemoteClusterEnabled() ? "cluster:admin/xpack/security/cross_cluster/api_key/update" : null, "cluster:admin/xpack/security/delegate_pki", "cluster:admin/xpack/security/enroll/node", "cluster:admin/xpack/security/enroll/kibana", @@ -520,5 +526,5 @@ public class Constants { "internal:cluster/formation/info", "internal:gateway/local/started_shards", "internal:admin/indices/prevalidate_shard_path" - ); + ).filter(Objects::nonNull).collect(Collectors.toUnmodifiableSet()); } diff --git a/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java b/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java index e0b981bbff8e9..8fc1f3be562f1 100644 --- a/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java +++ b/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java @@ -51,6 +51,7 @@ import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; @@ -743,27 +744,7 @@ public void testCreateCrossClusterApiKey() throws IOException { containsString("authentication expected API key type of [rest], but API key [" + apiKeyId + "] has type [cross_cluster]") ); - final Request fetchRequest; - if (randomBoolean()) { - fetchRequest = new Request("GET", "/_security/api_key"); - fetchRequest.addParameter("id", apiKeyId); - fetchRequest.addParameter("with_limited_by", String.valueOf(randomBoolean())); - } else { - fetchRequest = new Request("GET", "/_security/_query/api_key"); - fetchRequest.addParameter("with_limited_by", String.valueOf(randomBoolean())); - fetchRequest.setJsonEntity(Strings.format(""" - { "query": { "ids": { "values": ["%s"] } } }""", apiKeyId)); - } - - if (randomBoolean()) { - setUserForRequest(fetchRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); - } else { - setUserForRequest(fetchRequest, MANAGE_API_KEY_USER, END_USER_PASSWORD); - } - final ObjectPath fetchResponse = assertOKAndCreateObjectPath(client().performRequest(fetchRequest)); - - assertThat(fetchResponse.evaluate("api_keys.0.id"), equalTo(apiKeyId)); - assertThat(fetchResponse.evaluate("api_keys.0.type"), equalTo("cross_cluster")); + final ObjectPath fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); assertThat( fetchResponse.evaluate("api_keys.0.role_descriptors"), equalTo( @@ -882,6 +863,283 @@ public void testCrossClusterApiKeyRequiresName() throws IOException { }""", "Required [name]"); } + public void testUpdateCrossClusterApiKey() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(""" + { + "name": "cross-cluster-key", + "access": { + "search": [ + { + "names": [ "metrics" ] + } + ] + } + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath createResponse = assertOKAndCreateObjectPath(client().performRequest(createRequest)); + final String apiKeyId = createResponse.evaluate("id"); + + // Update both access and metadata + final Request updateRequest1 = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest1.setJsonEntity(""" + { + "access": { + "search": [ + { + "names": [ "data" ], + "query": "{\\"term\\":{\\"score\\":42}}" + } + ], + "replication": [ + { + "names": [ "logs" ] + } + ] + }, + "metadata": { "tag": "shared", "points": 0 } + }"""); + setUserForRequest(updateRequest1, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse1 = assertOKAndCreateObjectPath(client().performRequest(updateRequest1)); + assertThat(updateResponse1.evaluate("updated"), is(true)); + final RoleDescriptor updatedRoleDescriptor1 = new RoleDescriptor( + "cross_cluster", + new String[] { "cross_cluster_search", "cross_cluster_replication" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("data") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .query("{\"term\":{\"score\":42}}") + .build(), + RoleDescriptor.IndicesPrivileges.builder() + .indices("logs") + .privileges("cross_cluster_replication", "cross_cluster_replication_internal") + .build() }, + null + ); + + final ObjectPath fetchResponse1 = fetchCrossClusterApiKeyById(apiKeyId); + assertThat( + fetchResponse1.evaluate("api_keys.0.role_descriptors"), + equalTo(Map.of("cross_cluster", XContentTestUtils.convertToMap(updatedRoleDescriptor1))) + ); + assertThat(fetchResponse1.evaluate("api_keys.0.metadata"), equalTo(Map.of("tag", "shared", "points", 0))); + + // Update metadata only + final Request updateRequest2 = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + setUserForRequest(updateRequest2, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateRequest2.setJsonEntity(""" + { + "metadata": { "env": "prod", "magic": 42 } + }"""); + final ObjectPath updateResponse2 = assertOKAndCreateObjectPath(client().performRequest(updateRequest2)); + assertThat(updateResponse2.evaluate("updated"), is(true)); + final ObjectPath fetchResponse2 = fetchCrossClusterApiKeyById(apiKeyId); + assertThat( + fetchResponse2.evaluate("api_keys.0.role_descriptors"), + equalTo(Map.of("cross_cluster", XContentTestUtils.convertToMap(updatedRoleDescriptor1))) + ); + assertThat(fetchResponse2.evaluate("api_keys.0.metadata"), equalTo(Map.of("env", "prod", "magic", 42))); + + // Update access only + final Request updateRequest3 = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + setUserForRequest(updateRequest3, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateRequest3.setJsonEntity(""" + { + "access": { + "search": [ + { + "names": [ "blogs" ] + } + ] + } + }"""); + final ObjectPath updateResponse3 = assertOKAndCreateObjectPath(client().performRequest(updateRequest3)); + assertThat(updateResponse3.evaluate("updated"), is(true)); + final ObjectPath fetchResponse3 = fetchCrossClusterApiKeyById(apiKeyId); + final RoleDescriptor updatedRoleDescriptors2 = new RoleDescriptor( + "cross_cluster", + new String[] { "cross_cluster_search" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("blogs") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .build() }, + null + ); + assertThat( + fetchResponse3.evaluate("api_keys.0.role_descriptors"), + equalTo(Map.of("cross_cluster", XContentTestUtils.convertToMap(updatedRoleDescriptors2))) + ); + assertThat(fetchResponse3.evaluate("api_keys.0.metadata"), equalTo(Map.of("env", "prod", "magic", 42))); + + // Noop update + final Request updateRequest4 = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + setUserForRequest(updateRequest4, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateRequest4.setJsonEntity(randomFrom(""" + { + "access": { + "search": [ + { + "names": [ "blogs" ] + } + ] + } + }""", """ + { + "metadata": { "env": "prod", "magic": 42 } + }""", """ + { + "access": { + "search": [ + { + "names": [ "blogs" ] + } + ] + }, + "metadata": { "env": "prod", "magic": 42 } + }""")); + final ObjectPath updateResponse4 = assertOKAndCreateObjectPath(client().performRequest(updateRequest4)); + assertThat(updateResponse4.evaluate("updated"), is(false)); + final ObjectPath fetchResponse4 = fetchCrossClusterApiKeyById(apiKeyId); + assertThat( + fetchResponse4.evaluate("api_keys.0.role_descriptors"), + equalTo(Map.of("cross_cluster", XContentTestUtils.convertToMap(updatedRoleDescriptors2))) + ); + assertThat(fetchResponse4.evaluate("api_keys.0.metadata"), equalTo(Map.of("env", "prod", "magic", 42))); + } + + public void testUpdateFailureCases() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(""" + { + "name": "cross-cluster-key", + "access": { + "search": [ + { + "names": [ "metrics" ] + } + ] + } + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath createResponse = assertOKAndCreateObjectPath(client().performRequest(createRequest)); + final String apiKeyId = createResponse.evaluate("id"); + + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + // Request body is required + final ResponseException e1 = expectThrows(ResponseException.class, () -> client().performRequest(updateRequest)); + assertThat(e1.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e1.getMessage(), containsString("request body is required")); + + // Must update either access or metadata + updateRequest.setJsonEntity("{}"); + final ResponseException e2 = expectThrows(ResponseException.class, () -> client().performRequest(updateRequest)); + assertThat(e2.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e2.getMessage(), containsString("must update either [access] or [metadata] for cross-cluster API keys")); + + // Access cannot be empty + updateRequest.setJsonEntity("{\"access\":{}}"); + final ResponseException e3 = expectThrows(ResponseException.class, () -> client().performRequest(updateRequest)); + assertThat(e3.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e3.getMessage(), containsString("must specify non-empty access for either [search] or [replication]")); + + // Cannot update with API for REST API keys + final Request updateWithRestApi = new Request("PUT", "/_security/api_key/" + apiKeyId); + setUserForRequest(updateWithRestApi, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateWithRestApi.setJsonEntity("{\"metadata\":{}}"); + final ResponseException e4 = expectThrows(ResponseException.class, () -> client().performRequest(updateWithRestApi)); + assertThat(e4.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e4.getMessage(), containsString("cannot update API key of type [cross_cluster] while expected type is [rest]")); + + final Request updateWithBulkRestApi = new Request("POST", "/_security/api_key/_bulk_update"); + setUserForRequest(updateWithBulkRestApi, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateWithBulkRestApi.setJsonEntity("{\"ids\": [\"" + apiKeyId + "\"]}"); + final ObjectPath bulkUpdateResponse = assertOKAndCreateObjectPath(client().performRequest(updateWithBulkRestApi)); + assertThat(bulkUpdateResponse.evaluate("errors.count"), equalTo(1)); + assertThat( + bulkUpdateResponse.evaluate("errors.details." + apiKeyId + ".reason"), + containsString("cannot update API key of type [cross_cluster] while expected type is [rest]") + ); + + // Cannot update REST API key with cross-cluster API + final Request createRestApiKeyRequest = new Request("POST", "_security/api_key"); + setUserForRequest(createRestApiKeyRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + createRestApiKeyRequest.setJsonEntity("{\"name\":\"rest-key\"}"); + final ObjectPath createRestApiKeyResponse = assertOKAndCreateObjectPath(client().performRequest(createRestApiKeyRequest)); + final Request updateRestWithCrossClusterApi = new Request( + "PUT", + "/_security/cross_cluster/api_key/" + createRestApiKeyResponse.evaluate("id") + ); + setUserForRequest(updateRestWithCrossClusterApi, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateRestWithCrossClusterApi.setJsonEntity("{\"metadata\":{}}"); + final ResponseException e6 = expectThrows(ResponseException.class, () -> client().performRequest(updateRestWithCrossClusterApi)); + assertThat(e6.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e6.getMessage(), containsString("cannot update API key of type [rest] while expected type is [cross_cluster]")); + + // Cannot update other's API keys + final String anotherPowerUser = "another_power_user"; + createUser(anotherPowerUser, END_USER_PASSWORD, List.of("manage_security_role")); + setUserForRequest(createRequest, anotherPowerUser, END_USER_PASSWORD); + final ObjectPath anotherCrossClusterApiKey = assertOKAndCreateObjectPath(client().performRequest(createRequest)); + final Request anotherUpdateRequest = new Request( + "PUT", + "/_security/cross_cluster/api_key/" + anotherCrossClusterApiKey.evaluate("id") + ); + setUserForRequest(anotherUpdateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + anotherUpdateRequest.setJsonEntity("{\"metadata\":{}}"); + final ResponseException e7 = expectThrows(ResponseException.class, () -> client().performRequest(anotherUpdateRequest)); + assertThat(e7.getResponse().getStatusLine().getStatusCode(), equalTo(404)); + assertThat(e7.getMessage(), containsString("no API key owned by requesting user found")); + + // Cannot update cross-cluster API key with manage_api_key or manage_own_api_keys + createUser(anotherPowerUser, END_USER_PASSWORD, List.of(randomFrom("manage_api_key_role", "manage_own_api_key_role"))); + setUserForRequest(anotherUpdateRequest, anotherPowerUser, END_USER_PASSWORD); + anotherUpdateRequest.setJsonEntity("{\"metadata\":{}}"); + final ResponseException e8 = expectThrows(ResponseException.class, () -> client().performRequest(anotherUpdateRequest)); + assertThat(e8.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(e8.getMessage(), containsString("action [cluster:admin/xpack/security/cross_cluster/api_key/update] is unauthorized")); + + // Cross-cluster API key created by another API key cannot be updated + // This isn't the desired behaviour and more like a bug because we don't yet have a full story about API key's identity. + // Since we actively block it, we are checking it here. But it should be removed once we solve the issue of API key identity. + final Request createDerivedRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createDerivedRequest.setJsonEntity(""" + { + "name": "derived-cross-cluster-key", + "access": { + "replication": [ + { + "names": [ "logs" ] + } + ] + } + }"""); + createDerivedRequest.setOptions( + RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "ApiKey " + createRestApiKeyResponse.evaluate("encoded")) + ); + final ObjectPath createDerivedResponse = assertOKAndCreateObjectPath(client().performRequest(createDerivedRequest)); + final String derivedApiKey = createDerivedResponse.evaluate("id"); + // cannot be updated by the original creator user + final Request updateDerivedRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + derivedApiKey); + setUserForRequest(updateDerivedRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + updateDerivedRequest.setJsonEntity("{\"metadata\":{}}"); + final ResponseException e9 = expectThrows(ResponseException.class, () -> client().performRequest(updateDerivedRequest)); + assertThat(e9.getResponse().getStatusLine().getStatusCode(), equalTo(404)); + assertThat(e9.getMessage(), containsString("no API key owned by requesting user found")); + // cannot be updated by the original API key either + updateDerivedRequest.setOptions( + RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "ApiKey " + createRestApiKeyResponse.evaluate("encoded")) + ); + final ResponseException e10 = expectThrows(ResponseException.class, () -> client().performRequest(updateDerivedRequest)); + assertThat(e10.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e10.getMessage(), containsString("authentication via API key not supported: only the owner user can update an API key")); + } + private void assertBadCreateCrossClusterApiKeyRequest(String body, String expectedErrorMessage) throws IOException { final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); createRequest.setJsonEntity(body); @@ -1011,6 +1269,31 @@ private EncodedApiKey createApiKey(final String apiKeyName, final Map apiKeyDocMap = getApiKeyDocument(apiKeyId); final boolean useGetApiKey = randomBoolean(); final ApiKey apiKeyInfo = getApiKeyInfo(client(), apiKeyId, true, useGetApiKey); + // Update does not change API key type + assertThat(apiKeyDocMap.get("type"), equalTo("rest")); + assertThat(apiKeyInfo.getType(), equalTo(ApiKey.Type.REST)); for (Map.Entry entry : attributes.entrySet()) { switch (entry.getKey()) { case CREATOR -> { diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java index d6317ba2cac5d..44affb667f61f 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java @@ -27,6 +27,7 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.core.Tuple; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.test.SecuritySingleNodeTestCase; import org.elasticsearch.test.TestSecurityClient; @@ -38,9 +39,11 @@ import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequestBuilder; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse; @@ -51,6 +54,9 @@ import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction; import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest; import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse; @@ -429,7 +435,33 @@ public void testCreateCrossClusterApiKey() throws IOException { }"""); final PlainActionFuture future = new PlainActionFuture<>(); - client().execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + // Cross-cluster API keys can be created by an API key as long as it has manage_security + final boolean createWithUser = randomBoolean(); + if (createWithUser) { + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + } else { + final CreateApiKeyResponse createAdminKeyResponse = new CreateApiKeyRequestBuilder(client()).setName("admin-key") + .setRoleDescriptors( + randomFrom( + List.of(new RoleDescriptor(randomAlphaOfLengthBetween(3, 8), new String[] { "manage_security" }, null, null)), + null + ) + ) + .execute() + .actionGet(); + client().filterWithHeader( + Map.of( + "Authorization", + "ApiKey " + + Base64.getEncoder() + .encodeToString( + (createAdminKeyResponse.getId() + ":" + createAdminKeyResponse.getKey().toString()).getBytes( + StandardCharsets.UTF_8 + ) + ) + ) + ).execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + } final CreateApiKeyResponse createApiKeyResponse = future.actionGet(); final String apiKeyId = createApiKeyResponse.getId(); @@ -488,6 +520,13 @@ public void testCreateCrossClusterApiKey() throws IOException { assertThat(getApiKeyInfo.getType(), is(ApiKey.Type.CROSS_CLUSTER)); assertThat(getApiKeyInfo.getRoleDescriptors(), contains(expectedRoleDescriptor)); assertThat(getApiKeyInfo.getLimitedBy(), nullValue()); + assertThat(getApiKeyInfo.getMetadata(), anEmptyMap()); + assertThat(getApiKeyInfo.getUsername(), equalTo("test_user")); + if (createWithUser) { + assertThat(getApiKeyInfo.getRealm(), equalTo("file")); + } else { + assertThat(getApiKeyInfo.getRealm(), equalTo("_es_api_key")); + } // Check the API key attributes with Query API final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest( @@ -504,6 +543,114 @@ public void testCreateCrossClusterApiKey() throws IOException { assertThat(queryApiKeyInfo.getType(), is(ApiKey.Type.CROSS_CLUSTER)); assertThat(queryApiKeyInfo.getRoleDescriptors(), contains(expectedRoleDescriptor)); assertThat(queryApiKeyInfo.getLimitedBy(), nullValue()); + assertThat(queryApiKeyInfo.getMetadata(), anEmptyMap()); + assertThat(queryApiKeyInfo.getUsername(), equalTo("test_user")); + if (createWithUser) { + assertThat(queryApiKeyInfo.getRealm(), equalTo("file")); + } else { + assertThat(queryApiKeyInfo.getRealm(), equalTo("_es_api_key")); + } + } + + public void testUpdateCrossClusterApiKey() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + + final RoleDescriptor originalRoleDescriptor = new RoleDescriptor( + "cross_cluster", + new String[] { "cross_cluster_search" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("logs") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .build() }, + null + ); + final var createApiKeyRequest = CreateCrossClusterApiKeyRequest.withNameAndAccess(randomAlphaOfLengthBetween(3, 8), """ + { + "search": [ {"names": ["logs"]} ] + }"""); + final CreateApiKeyResponse createApiKeyResponse = client().execute(CreateCrossClusterApiKeyAction.INSTANCE, createApiKeyRequest) + .actionGet(); + final String apiKeyId = createApiKeyResponse.getId(); + + final GetApiKeyResponse getApiKeyResponse = client().execute( + GetApiKeyAction.INSTANCE, + GetApiKeyRequest.builder().apiKeyId(apiKeyId).withLimitedBy(randomBoolean()).build() + ).actionGet(); + assertThat(getApiKeyResponse.getApiKeyInfos(), arrayWithSize(1)); + final ApiKey getApiKeyInfo = getApiKeyResponse.getApiKeyInfos()[0]; + assertThat(getApiKeyInfo.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(getApiKeyInfo.getRoleDescriptors(), contains(originalRoleDescriptor)); + assertThat(getApiKeyInfo.getLimitedBy(), nullValue()); + assertThat(getApiKeyInfo.getMetadata(), anEmptyMap()); + assertThat(getApiKeyInfo.getUsername(), equalTo("test_user")); + assertThat(getApiKeyInfo.getRealm(), equalTo("file")); + + final CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder; + final boolean shouldUpdateAccess = randomBoolean(); + if (shouldUpdateAccess) { + roleDescriptorBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(randomFrom(""" + { + "search": [ {"names": ["logs"]} ] + }""", """ + { + "search": [ {"names": ["metrics"]} ] + }""", """ + { + "replication": [ {"names": ["archive"]} ] + }""", """ + { + "search": [ {"names": ["logs"]} ], + "replication": [ {"names": ["archive"]} ] + }""")); + } else { + roleDescriptorBuilder = null; + } + + final Map updateMetadata; + final boolean shouldUpdateMetadata = shouldUpdateAccess == false || randomBoolean(); + if (shouldUpdateMetadata) { + updateMetadata = randomFrom( + randomMap( + 0, + 5, + () -> new Tuple<>(randomAlphaOfLengthBetween(8, 12), randomFrom(randomAlphaOfLengthBetween(3, 8), 42, randomBoolean())) + ) + ); + } else { + updateMetadata = null; + } + + final var updateApiKeyRequest = new UpdateCrossClusterApiKeyRequest(apiKeyId, roleDescriptorBuilder, updateMetadata); + final UpdateApiKeyResponse updateApiKeyResponse = client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateApiKeyRequest) + .actionGet(); + + if ((roleDescriptorBuilder == null || roleDescriptorBuilder.build().equals(originalRoleDescriptor)) && (updateMetadata == null)) { + assertThat(updateApiKeyResponse.isUpdated(), is(false)); + } else { + assertThat(updateApiKeyResponse.isUpdated(), is(true)); + } + + final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest( + QueryBuilders.boolQuery().filter(QueryBuilders.idsQuery().addIds(apiKeyId)), + null, + null, + null, + null, + randomBoolean() + ); + final QueryApiKeyResponse queryApiKeyResponse = client().execute(QueryApiKeyAction.INSTANCE, queryApiKeyRequest).actionGet(); + assertThat(queryApiKeyResponse.getItems(), arrayWithSize(1)); + final ApiKey queryApiKeyInfo = queryApiKeyResponse.getItems()[0].getApiKey(); + assertThat(queryApiKeyInfo.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat( + queryApiKeyInfo.getRoleDescriptors(), + contains(roleDescriptorBuilder == null ? originalRoleDescriptor : roleDescriptorBuilder.build()) + ); + assertThat(queryApiKeyInfo.getLimitedBy(), nullValue()); + assertThat(queryApiKeyInfo.getMetadata(), equalTo(updateMetadata == null ? Map.of() : updateMetadata)); + assertThat(queryApiKeyInfo.getUsername(), equalTo("test_user")); + assertThat(queryApiKeyInfo.getRealm(), equalTo("file")); } private GrantApiKeyRequest buildGrantApiKeyRequest(String username, SecureString password, String runAsUsername) throws IOException { 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 083333ae0b00e..d4d25d77c475e 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 @@ -118,6 +118,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction; import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction; @@ -202,6 +203,7 @@ import org.elasticsearch.xpack.security.action.apikey.TransportInvalidateApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportQueryApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportUpdateApiKeyAction; +import org.elasticsearch.xpack.security.action.apikey.TransportUpdateCrossClusterApiKeyAction; import org.elasticsearch.xpack.security.action.enrollment.TransportKibanaEnrollmentAction; import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; @@ -306,6 +308,7 @@ import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestQueryApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestUpdateApiKeyAction; +import org.elasticsearch.xpack.security.rest.action.apikey.RestUpdateCrossClusterApiKeyAction; import org.elasticsearch.xpack.security.rest.action.enrollment.RestKibanaEnrollAction; import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; @@ -1312,6 +1315,9 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(QueryApiKeyAction.INSTANCE, TransportQueryApiKeyAction.class), new ActionHandler<>(UpdateApiKeyAction.INSTANCE, TransportUpdateApiKeyAction.class), new ActionHandler<>(BulkUpdateApiKeyAction.INSTANCE, TransportBulkUpdateApiKeyAction.class), + TcpTransport.isUntrustedRemoteClusterEnabled() + ? new ActionHandler<>(UpdateCrossClusterApiKeyAction.INSTANCE, TransportUpdateCrossClusterApiKeyAction.class) + : null, new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class), new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class), new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class), @@ -1392,6 +1398,7 @@ public List getRestHandlers( TcpTransport.isUntrustedRemoteClusterEnabled() ? new RestCreateCrossClusterApiKeyAction(settings, getLicenseState()) : null, new RestUpdateApiKeyAction(settings, getLicenseState()), new RestBulkUpdateApiKeyAction(settings, getLicenseState()), + TcpTransport.isUntrustedRemoteClusterEnabled() ? new RestUpdateCrossClusterApiKeyAction(settings, getLicenseState()) : null, new RestGrantApiKeyAction(settings, getLicenseState()), new RestInvalidateApiKeyAction(settings, getLicenseState()), new RestGetApiKeyAction(settings, getLicenseState()), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBaseUpdateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBaseUpdateApiKeyAction.java index 978c71e6601bb..3753eefb1c49d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBaseUpdateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBaseUpdateApiKeyAction.java @@ -14,34 +14,28 @@ import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; -import org.elasticsearch.xcontent.NamedXContentRegistry; import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.action.apikey.BaseUpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; -import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; -import org.elasticsearch.xpack.security.authc.support.ApiKeyUserRoleDescriptorResolver; -import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; -import java.util.Set; +import java.util.Map; public abstract class TransportBaseUpdateApiKeyAction extends HandledTransportAction { private final SecurityContext securityContext; - private final ApiKeyUserRoleDescriptorResolver resolver; protected TransportBaseUpdateApiKeyAction( final String actionName, final TransportService transportService, final ActionFilters actionFilters, final Writeable.Reader requestReader, - final SecurityContext context, - final CompositeRolesStore rolesStore, - final NamedXContentRegistry xContentRegistry + final SecurityContext context ) { super(actionName, transportService, actionFilters, requestReader); this.securityContext = context; - this.resolver = new ApiKeyUserRoleDescriptorResolver(rolesStore, xContentRegistry); } @Override @@ -57,20 +51,41 @@ public final void doExecute(Task task, Request request, ActionListener return; } - resolver.resolveUserRoleDescriptors( - authentication, - ActionListener.wrap( - roleDescriptors -> doExecuteUpdate(task, request, authentication, roleDescriptors, listener), - listener::onFailure - ) - ); + doExecuteUpdate(task, request, authentication, listener); } - abstract void doExecuteUpdate( - Task task, - Request request, - Authentication authentication, - Set roleDescriptors, - ActionListener listener - ); + abstract void doExecuteUpdate(Task task, Request request, Authentication authentication, ActionListener listener); + + protected UpdateApiKeyResponse toSingleResponse(final String apiKeyId, final BulkUpdateApiKeyResponse response) throws Exception { + if (response.getTotalResultCount() != 1) { + throw new IllegalStateException( + "single result required for single API key update but result count was [" + response.getTotalResultCount() + "]" + ); + } + if (response.getErrorDetails().isEmpty() == false) { + final Map.Entry errorEntry = response.getErrorDetails().entrySet().iterator().next(); + if (errorEntry.getKey().equals(apiKeyId) == false) { + throwIllegalStateExceptionOnIdMismatch(apiKeyId, errorEntry.getKey()); + } + throw errorEntry.getValue(); + } else if (response.getUpdated().isEmpty() == false) { + final String updatedId = response.getUpdated().get(0); + if (updatedId.equals(apiKeyId) == false) { + throwIllegalStateExceptionOnIdMismatch(apiKeyId, updatedId); + } + return new UpdateApiKeyResponse(true); + } else { + final String noopId = response.getNoops().get(0); + if (noopId.equals(apiKeyId) == false) { + throwIllegalStateExceptionOnIdMismatch(apiKeyId, noopId); + } + return new UpdateApiKeyResponse(false); + } + } + + private void throwIllegalStateExceptionOnIdMismatch(final String requestId, final String responseId) { + final String message = "response ID [" + responseId + "] does not match request ID [" + requestId + "] for single API key update"; + assert false : message; + throw new IllegalStateException(message); + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBulkUpdateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBulkUpdateApiKeyAction.java index 7d3432cef8314..cb8f6c861ecf7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBulkUpdateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportBulkUpdateApiKeyAction.java @@ -18,17 +18,16 @@ import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; -import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.support.ApiKeyUserRoleDescriptorResolver; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; -import java.util.Set; - public final class TransportBulkUpdateApiKeyAction extends TransportBaseUpdateApiKeyAction< BulkUpdateApiKeyRequest, BulkUpdateApiKeyResponse> { private final ApiKeyService apiKeyService; + private final ApiKeyUserRoleDescriptorResolver resolver; @Inject public TransportBulkUpdateApiKeyAction( @@ -39,16 +38,9 @@ public TransportBulkUpdateApiKeyAction( final CompositeRolesStore rolesStore, final NamedXContentRegistry xContentRegistry ) { - super( - BulkUpdateApiKeyAction.NAME, - transportService, - actionFilters, - BulkUpdateApiKeyRequest::new, - context, - rolesStore, - xContentRegistry - ); + super(BulkUpdateApiKeyAction.NAME, transportService, actionFilters, BulkUpdateApiKeyRequest::new, context); this.apiKeyService = apiKeyService; + this.resolver = new ApiKeyUserRoleDescriptorResolver(rolesStore, xContentRegistry); } @Override @@ -56,9 +48,14 @@ void doExecuteUpdate( final Task task, final BulkUpdateApiKeyRequest request, final Authentication authentication, - final Set roleDescriptors, final ActionListener listener ) { - apiKeyService.updateApiKeys(authentication, request, roleDescriptors, listener); + resolver.resolveUserRoleDescriptors( + authentication, + ActionListener.wrap( + roleDescriptors -> apiKeyService.updateApiKeys(authentication, request, roleDescriptors, listener), + listener::onFailure + ) + ); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java index 19bfeb7ff378b..2427b571cf575 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java @@ -15,21 +15,18 @@ import org.elasticsearch.xcontent.NamedXContentRegistry; import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; -import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; -import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.support.ApiKeyUserRoleDescriptorResolver; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; -import java.util.Map; -import java.util.Set; - public final class TransportUpdateApiKeyAction extends TransportBaseUpdateApiKeyAction { private final ApiKeyService apiKeyService; + private final ApiKeyUserRoleDescriptorResolver resolver; @Inject public TransportUpdateApiKeyAction( @@ -40,8 +37,9 @@ public TransportUpdateApiKeyAction( final CompositeRolesStore rolesStore, final NamedXContentRegistry xContentRegistry ) { - super(UpdateApiKeyAction.NAME, transportService, actionFilters, UpdateApiKeyRequest::new, context, rolesStore, xContentRegistry); + super(UpdateApiKeyAction.NAME, transportService, actionFilters, UpdateApiKeyRequest::new, context); this.apiKeyService = apiKeyService; + this.resolver = new ApiKeyUserRoleDescriptorResolver(rolesStore, xContentRegistry); } @Override @@ -49,47 +47,22 @@ void doExecuteUpdate( final Task task, final UpdateApiKeyRequest request, final Authentication authentication, - final Set roleDescriptors, final ActionListener listener ) { - apiKeyService.updateApiKeys( + resolver.resolveUserRoleDescriptors( authentication, - BulkUpdateApiKeyRequest.wrap(request), - roleDescriptors, - ActionListener.wrap(bulkResponse -> listener.onResponse(toSingleResponse(request.getId(), bulkResponse)), listener::onFailure) + ActionListener.wrap( + roleDescriptors -> apiKeyService.updateApiKeys( + authentication, + BulkUpdateApiKeyRequest.wrap(request), + roleDescriptors, + ActionListener.wrap( + bulkResponse -> listener.onResponse(toSingleResponse(request.getId(), bulkResponse)), + listener::onFailure + ) + ), + listener::onFailure + ) ); } - - private UpdateApiKeyResponse toSingleResponse(final String apiKeyId, final BulkUpdateApiKeyResponse response) throws Exception { - if (response.getTotalResultCount() != 1) { - throw new IllegalStateException( - "single result required for single API key update but result count was [" + response.getTotalResultCount() + "]" - ); - } - if (response.getErrorDetails().isEmpty() == false) { - final Map.Entry errorEntry = response.getErrorDetails().entrySet().iterator().next(); - if (errorEntry.getKey().equals(apiKeyId) == false) { - throwIllegalStateExceptionOnIdMismatch(apiKeyId, errorEntry.getKey()); - } - throw errorEntry.getValue(); - } else if (response.getUpdated().isEmpty() == false) { - final String updatedId = response.getUpdated().get(0); - if (updatedId.equals(apiKeyId) == false) { - throwIllegalStateExceptionOnIdMismatch(apiKeyId, updatedId); - } - return new UpdateApiKeyResponse(true); - } else { - final String noopId = response.getNoops().get(0); - if (noopId.equals(apiKeyId) == false) { - throwIllegalStateExceptionOnIdMismatch(apiKeyId, noopId); - } - return new UpdateApiKeyResponse(false); - } - } - - private void throwIllegalStateExceptionOnIdMismatch(final String requestId, final String responseId) { - final String message = "response ID [" + responseId + "] does not match request ID [" + requestId + "] for single API key update"; - assert false : message; - throw new IllegalStateException(message); - } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..011b95565e030 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyAction.java @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.action.apikey; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; +import org.elasticsearch.xpack.core.security.action.apikey.BaseBulkUpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +import java.util.List; +import java.util.Set; + +public final class TransportUpdateCrossClusterApiKeyAction extends TransportBaseUpdateApiKeyAction< + UpdateCrossClusterApiKeyRequest, + UpdateApiKeyResponse> { + + private final ApiKeyService apiKeyService; + + @Inject + public TransportUpdateCrossClusterApiKeyAction( + final TransportService transportService, + final ActionFilters actionFilters, + final ApiKeyService apiKeyService, + final SecurityContext context + ) { + super(UpdateCrossClusterApiKeyAction.NAME, transportService, actionFilters, UpdateCrossClusterApiKeyRequest::new, context); + this.apiKeyService = apiKeyService; + } + + @Override + void doExecuteUpdate( + final Task task, + final UpdateCrossClusterApiKeyRequest request, + final Authentication authentication, + final ActionListener listener + ) { + apiKeyService.updateApiKeys( + authentication, + new BaseBulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata()) { + @Override + public ApiKey.Type getType() { + return ApiKey.Type.CROSS_CLUSTER; + } + }, + Set.of(), + ActionListener.wrap(bulkResponse -> listener.onResponse(toSingleResponse(request.getId(), bulkResponse)), 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 36f1445d3075a..cdb1a95480ecd 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 @@ -85,8 +85,8 @@ import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheResponse; import org.elasticsearch.xpack.core.security.action.apikey.AbstractCreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; +import org.elasticsearch.xpack.core.security.action.apikey.BaseBulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BaseUpdateApiKeyRequest; -import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse; @@ -298,7 +298,8 @@ public void createApiKey( Set userRoleDescriptors, ActionListener listener ) { - assert request.getType() != ApiKey.Type.CROSS_CLUSTER || userRoleDescriptors.isEmpty(); + assert request.getType() != ApiKey.Type.CROSS_CLUSTER || userRoleDescriptors.isEmpty() + : "owner user role descriptor must be empty for cross-cluster API keys"; ensureEnabled(); if (authentication == null) { listener.onFailure(new IllegalArgumentException("authentication must be provided")); @@ -410,10 +411,12 @@ private void createApiKeyAndIndexIt( public void updateApiKeys( final Authentication authentication, - final BulkUpdateApiKeyRequest request, + final BaseBulkUpdateApiKeyRequest request, final Set userRoleDescriptors, final ActionListener listener ) { + assert request.getType() != ApiKey.Type.CROSS_CLUSTER || userRoleDescriptors.isEmpty() + : "owner user role descriptor must be empty for cross-cluster API keys"; ensureEnabled(); if (authentication == null) { @@ -462,7 +465,7 @@ && hasRemoteIndices(request.getRoleDescriptors())) { private void updateApiKeys( final Authentication authentication, - final BulkUpdateApiKeyRequest request, + final BaseBulkUpdateApiKeyRequest request, final Set userRoleDescriptors, final Collection targetVersionedDocs, final ActionListener listener @@ -479,7 +482,7 @@ private void updateApiKeys( for (VersionedApiKeyDoc versionedDoc : targetVersionedDocs) { final String apiKeyId = versionedDoc.id(); try { - validateForUpdate(apiKeyId, authentication, versionedDoc.doc()); + validateForUpdate(apiKeyId, request.getType(), authentication, versionedDoc.doc()); final IndexRequest indexRequest = maybeBuildIndexRequest(versionedDoc, authentication, request, userRoleDescriptors); final boolean isNoop = indexRequest == null; if (isNoop) { @@ -517,7 +520,12 @@ private void updateApiKeys( } // package-private for testing - void validateForUpdate(final String apiKeyId, final Authentication authentication, final ApiKeyDoc apiKeyDoc) { + void validateForUpdate( + final String apiKeyId, + final ApiKey.Type expectedType, + final Authentication authentication, + final ApiKeyDoc apiKeyDoc + ) { assert authentication.getEffectiveSubject().getUser().principal().equals(apiKeyDoc.creator.get("principal")); if (apiKeyDoc.invalidated) { @@ -532,6 +540,12 @@ void validateForUpdate(final String apiKeyId, final Authentication authenticatio if (Strings.isNullOrEmpty(apiKeyDoc.name)) { throw new IllegalArgumentException("cannot update legacy API key [" + apiKeyId + "] without name"); } + + if (expectedType != apiKeyDoc.type) { + throw new IllegalArgumentException( + "cannot update API key of type [" + apiKeyDoc.type.value() + "] while expected type is [" + expectedType.value() + "]" + ); + } } /** @@ -666,6 +680,7 @@ XContentBuilder maybeBuildUpdatedDocument( final BaseUpdateApiKeyRequest request, final Set userRoleDescriptors ) throws IOException { + assert currentApiKeyDoc.type == request.getType(); if (isNoop(apiKeyId, currentApiKeyDoc, targetDocVersion, authentication, request, userRoleDescriptors)) { return null; } @@ -673,6 +688,7 @@ XContentBuilder maybeBuildUpdatedDocument( final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject() .field("doc_type", "api_key") + .field("type", currentApiKeyDoc.type.value()) .field("creation_time", currentApiKeyDoc.creationTime) .field("expiration_time", currentApiKeyDoc.expirationTime == -1 ? null : currentApiKeyDoc.expirationTime) .field("api_key_invalidated", false); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..7453609f6bbe0 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyAction.java @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.rest.action.apikey; + +import org.elasticsearch.client.internal.node.NodeClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.rest.RestRequest.Method.PUT; +import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHandler { + + @SuppressWarnings("unchecked") + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "update_cross_cluster_api_key_request_payload", + a -> new Payload((CrossClusterApiKeyRoleDescriptorBuilder) a[0], (Map) a[1]) + ); + + static { + PARSER.declareObject(optionalConstructorArg(), CrossClusterApiKeyRoleDescriptorBuilder.PARSER, new ParseField("access")); + PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); + } + + public RestUpdateCrossClusterApiKeyAction(final Settings settings, final XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public List routes() { + return List.of(new Route(PUT, "/_security/cross_cluster/api_key/{id}")); + } + + @Override + public String getName() { + return "xpack_security_update_cross_cluster_api_key"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException { + final var apiKeyId = request.param("id"); + final Payload payload = PARSER.parse(request.contentParser(), null); + + return channel -> client.execute( + UpdateCrossClusterApiKeyAction.INSTANCE, + new UpdateCrossClusterApiKeyRequest(apiKeyId, payload.builder, payload.metadata), + new RestToXContentListener<>(channel) + ); + } + + record Payload(CrossClusterApiKeyRoleDescriptorBuilder builder, Map metadata) {} +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyActionTests.java new file mode 100644 index 0000000000000..711c604206823 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyActionTests.java @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.action.apikey; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; +import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests; +import org.elasticsearch.xpack.core.security.action.apikey.BaseBulkUpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.elasticsearch.test.ActionListenerUtils.anyActionListener; +import static org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequestTests.randomCrossClusterApiKeyAccessField; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportUpdateCrossClusterApiKeyActionTests extends ESTestCase { + + public void testExecute() throws IOException { + final ApiKeyService apiKeyService = mock(ApiKeyService.class); + final SecurityContext securityContext = mock(SecurityContext.class); + final Authentication authentication = randomValueOtherThanMany( + Authentication::isApiKey, + () -> AuthenticationTestHelper.builder().build() + ); + when(securityContext.getAuthentication()).thenReturn(authentication); + final var action = new TransportUpdateCrossClusterApiKeyAction( + mock(TransportService.class), + mock(ActionFilters.class), + apiKeyService, + securityContext + ); + + final Map metadata = ApiKeyTests.randomMetadata(); + + final CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder; + if (metadata == null || randomBoolean()) { + roleDescriptorBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(randomCrossClusterApiKeyAccessField()); + } else { + roleDescriptorBuilder = null; + } + + final String id = randomAlphaOfLength(10); + final var request = new UpdateCrossClusterApiKeyRequest(id, roleDescriptorBuilder, metadata); + final int updateStatus = randomIntBetween(0, 2); // 0 - success, 1 - noop, 2 - error + + doAnswer(invocation -> { + final var bulkRequest = (BaseBulkUpdateApiKeyRequest) invocation.getArgument(1); + assertThat(bulkRequest.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(bulkRequest.getIds(), contains(id)); + if (roleDescriptorBuilder != null) { + assertThat(bulkRequest.getRoleDescriptors(), contains(roleDescriptorBuilder.build())); + } else { + assertThat(bulkRequest.getRoleDescriptors(), nullValue()); + } + if (metadata != null) { + assertThat(bulkRequest.getMetadata(), equalTo(metadata)); + } else { + assertThat(bulkRequest.getMetadata(), nullValue()); + } + + final Set userRoleDescriptors = invocation.getArgument(2); + assertThat(userRoleDescriptors, empty()); + + final ActionListener listener = invocation.getArgument(3); + final BulkUpdateApiKeyResponse response = switch (updateStatus) { + case 0 -> new BulkUpdateApiKeyResponse(List.of(id), List.of(), Map.of()); + case 1 -> new BulkUpdateApiKeyResponse(List.of(), List.of(id), Map.of()); + case 2 -> new BulkUpdateApiKeyResponse(List.of(), List.of(), Map.of(id, new IllegalArgumentException("invalid"))); + default -> throw new IllegalArgumentException("unknown update status " + updateStatus); + }; + listener.onResponse(response); + return null; + }).when(apiKeyService).updateApiKeys(same(authentication), any(BaseBulkUpdateApiKeyRequest.class), any(), anyActionListener()); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future); + + switch (updateStatus) { + case 0 -> assertThat(future.actionGet().isUpdated(), is(true)); + case 1 -> assertThat(future.actionGet().isUpdated(), is(false)); + case 2 -> { + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, future::actionGet); + assertThat(e.getMessage(), equalTo("invalid")); + } + default -> throw new IllegalArgumentException("unknown update status " + updateStatus); + } + } + + public void testAuthenticationCheck() { + final SecurityContext securityContext = mock(SecurityContext.class); + final var action = new TransportUpdateCrossClusterApiKeyAction( + mock(TransportService.class), + mock(ActionFilters.class), + mock(ApiKeyService.class), + securityContext + ); + final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, Map.of()); + + // null authentication error + when(securityContext.getAuthentication()).thenReturn(null); + final PlainActionFuture future1 = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future1); + final IllegalStateException e1 = expectThrows(IllegalStateException.class, future1::actionGet); + assertThat(e1.getMessage(), containsString("authentication is required")); + + // Cannot update with API keys + when(securityContext.getAuthentication()).thenReturn(AuthenticationTestHelper.builder().apiKey().build()); + final PlainActionFuture future2 = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future2); + final IllegalArgumentException e2 = expectThrows(IllegalArgumentException.class, future2::actionGet); + assertThat(e2.getMessage(), containsString("authentication via API key not supported: only the owner user can update an API key")); + } +} 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 8a78c68b2d3c3..4bf61112bd94e 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 @@ -85,13 +85,14 @@ import org.elasticsearch.xpack.core.security.action.apikey.AbstractCreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests; +import org.elasticsearch.xpack.core.security.action.apikey.BaseUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse; -import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; @@ -121,6 +122,7 @@ import org.mockito.Mockito; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; @@ -151,6 +153,7 @@ import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests; import static org.elasticsearch.test.TestMatchers.throwableWithMessage; import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCS; +import static org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequestTests.randomCrossClusterApiKeyAccessField; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_ID_KEY; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_METADATA_KEY; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_TYPE_KEY; @@ -732,7 +735,7 @@ private Map mockKeyDocument( getFastStoredHashAlgoForTests().hash(new SecureString(key.toCharArray())), "test", authentication, - Collections.singleton(SUPERUSER_ROLE_DESCRIPTOR), + type == ApiKey.Type.CROSS_CLUSTER ? Set.of() : Collections.singleton(SUPERUSER_ROLE_DESCRIPTOR), Instant.now(), Instant.now().plus(expiry), keyRoles, @@ -1335,11 +1338,12 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru assertNotNull(service.getDocCache()); assertNotNull(service.getRoleDescriptorsBytesCache()); final ThreadContext threadContext = threadPool.getThreadContext(); + final ApiKey.Type type = ApiKey.Type.REST; // 1. A new API key document will be cached after its authentication final String docId = randomAlphaOfLength(16); final String apiKey = randomAlphaOfLength(16); - final ApiKey.Type type = randomFrom(ApiKey.Type.values()); + ApiKeyCredentials apiKeyCredentials = getApiKeyCredentials(docId, apiKey, type); final Map metadata = mockKeyDocument( docId, @@ -1350,7 +1354,6 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru Duration.ofSeconds(3600), null, type - ); PlainActionFuture> future = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials, future); @@ -1380,8 +1383,7 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru // 2. A different API Key with the same role descriptors will share the entries in the role descriptor cache final String docId2 = randomAlphaOfLength(16); final String apiKey2 = randomAlphaOfLength(16); - final ApiKey.Type type2 = randomFrom(ApiKey.Type.values()); - ApiKeyCredentials apiKeyCredentials2 = getApiKeyCredentials(docId2, apiKey2, type2); + ApiKeyCredentials apiKeyCredentials2 = getApiKeyCredentials(docId2, apiKey2, type); final Map metadata2 = mockKeyDocument( docId2, apiKey2, @@ -1390,7 +1392,7 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru false, Duration.ofSeconds(3600), null, - type2 + type ); PlainActionFuture> future2 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials2, future2); @@ -1407,13 +1409,12 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru } else { assertThat(cachedApiKeyDoc2.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata2, XContentType.JSON))); } - assertThat(cachedApiKeyDoc2.type, is(type2)); + assertThat(cachedApiKeyDoc2.type, is(type)); // 3. Different role descriptors will be cached into a separate entry final String docId3 = randomAlphaOfLength(16); final String apiKey3 = randomAlphaOfLength(16); - final ApiKey.Type type3 = randomFrom(ApiKey.Type.values()); - ApiKeyCredentials apiKeyCredentials3 = getApiKeyCredentials(docId3, apiKey3, type3); + ApiKeyCredentials apiKeyCredentials3 = getApiKeyCredentials(docId3, apiKey3, type); final List keyRoles = List.of( RoleDescriptor.parse("key-role", new BytesArray("{\"cluster\":[\"monitor\"]}"), true, XContentType.JSON) ); @@ -1425,7 +1426,7 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru false, Duration.ofSeconds(3600), keyRoles, - type3 + type ); PlainActionFuture> future3 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials3, future3); @@ -1446,11 +1447,45 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru } else { assertThat(cachedApiKeyDoc3.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata3, XContentType.JSON))); } - assertThat(cachedApiKeyDoc3.type, is(type3)); + assertThat(cachedApiKeyDoc3.type, is(type)); + + // 3.1. Cross-cluster API keys can share the cache entry + final String docId31 = randomAlphaOfLength(16); + final String apiKey31 = randomAlphaOfLength(16); + ApiKeyCredentials apiKeyCredentials31 = getApiKeyCredentials(docId31, apiKey31, ApiKey.Type.CROSS_CLUSTER); + final Map metadata31 = mockKeyDocument( + docId31, + apiKey31, + new User("stark", "superuser"), + null, + false, + Duration.ofSeconds(3600), + keyRoles, + ApiKey.Type.CROSS_CLUSTER + ); + service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials31, new PlainActionFuture<>()); + final ApiKeyService.CachedApiKeyDoc cachedApiKeyDoc31 = service.getDocCache().get(docId31); + assertNotNull(cachedApiKeyDoc31); + assertEquals("stark", cachedApiKeyDoc31.creator.get("principal")); + // Both role descriptor and limited-by role descriptor share cache entries. + assertEquals(3, service.getRoleDescriptorsBytesCache().count()); + // Cross cluster API keys have empty limited-by + assertThat( + service.getRoleDescriptorsBytesCache().get(cachedApiKeyDoc31.limitedByRoleDescriptorsHash).utf8ToString(), + equalTo("{}") + ); + if (metadata31 == null) { + assertNull(cachedApiKeyDoc31.metadataFlattened); + } else { + assertThat(cachedApiKeyDoc31.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata31, XContentType.JSON))); + } + assertThat(service.getRoleDescriptorsBytesCache().get(cachedApiKeyDoc31.roleDescriptorsHash), sameInstance(roleDescriptorsBytes3)); + assertThat(cachedApiKeyDoc31.type, is(ApiKey.Type.CROSS_CLUSTER)); // 4. Will fetch document from security index if role descriptors are not found even when // cachedApiKeyDoc is available service.getRoleDescriptorsBytesCache().invalidateAll(); + Mockito.clearInvocations(client); final Map metadata4 = mockKeyDocument( docId, apiKey, @@ -1463,7 +1498,7 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru ); PlainActionFuture> future4 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, getApiKeyCredentials(docId, apiKey, type), future4); - verify(client, times(4)).get(any(GetRequest.class), anyActionListener()); + verify(client, times(1)).get(any(GetRequest.class), anyActionListener()); assertEquals(2, service.getRoleDescriptorsBytesCache().count()); final AuthenticationResult authResult4 = future4.get(); assertSame(AuthenticationResult.Status.SUCCESS, authResult4.getStatus()); @@ -1833,12 +1868,28 @@ public void testValidateApiKeyDocBeforeUpdate() throws IOException { new Authentication.RealmRef("realm1", "realm_type1", "node") ); - var ex = expectThrows(IllegalArgumentException.class, () -> apiKeyService.validateForUpdate(apiKeyId, auth, apiKeyDocWithNullName)); + var ex = expectThrows( + IllegalArgumentException.class, + () -> apiKeyService.validateForUpdate(apiKeyId, apiKeyDocWithNullName.type, auth, apiKeyDocWithNullName) + ); assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name")); final var apiKeyDocWithEmptyName = buildApiKeyDoc(hash, -1, false, "", Version.V_8_2_0.id); - ex = expectThrows(IllegalArgumentException.class, () -> apiKeyService.validateForUpdate(apiKeyId, auth, apiKeyDocWithEmptyName)); + ex = expectThrows( + IllegalArgumentException.class, + () -> apiKeyService.validateForUpdate(apiKeyId, apiKeyDocWithEmptyName.type, auth, apiKeyDocWithEmptyName) + ); assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name")); + + final ApiKeyDoc apiKeyDoc = buildApiKeyDoc(hash, -1, false, randomAlphaOfLengthBetween(3, 8), Version.CURRENT.id); + final ApiKey.Type expectedType = randomValueOtherThan(apiKeyDoc.type, () -> randomFrom(ApiKey.Type.values())); + ex = expectThrows(IllegalArgumentException.class, () -> apiKeyService.validateForUpdate(apiKeyId, expectedType, auth, apiKeyDoc)); + assertThat( + ex.getMessage(), + containsString( + "cannot update API key of type [" + apiKeyDoc.type.value() + "] while expected type is [" + expectedType.value() + "]" + ) + ); } public void testMaybeBuildUpdatedDocument() throws IOException { @@ -1851,8 +1902,16 @@ public void testMaybeBuildUpdatedDocument() throws IOException { .user(AuthenticationTestHelper.userWithRandomMetadataAndDetails("user", "role")) .build(false) ); - final Set oldUserRoles = randomSet(0, 3, RoleDescriptorTests::randomRoleDescriptor); - final List oldKeyRoles = randomList(3, RoleDescriptorTests::randomRoleDescriptor); + final ApiKey.Type type = randomFrom(ApiKey.Type.values()); + final Set oldUserRoles = type == ApiKey.Type.CROSS_CLUSTER + ? Set.of() + : randomSet(0, 3, RoleDescriptorTests::randomRoleDescriptor); + final List oldKeyRoles; + if (type == ApiKey.Type.CROSS_CLUSTER) { + oldKeyRoles = List.of(CrossClusterApiKeyRoleDescriptorBuilder.parse(randomCrossClusterApiKeyAccessField()).build()); + } else { + oldKeyRoles = randomList(3, RoleDescriptorTests::randomRoleDescriptor); + } final Map oldMetadata = ApiKeyTests.randomMetadata(); final Version oldVersion = VersionUtils.randomVersion(random()); final ApiKeyDoc oldApiKeyDoc = ApiKeyDoc.fromXContent( @@ -1867,7 +1926,7 @@ public void testMaybeBuildUpdatedDocument() throws IOException { Instant.now(), randomBoolean() ? null : Instant.now(), oldKeyRoles, - randomFrom(ApiKey.Type.values()), + type, oldVersion, oldMetadata ) @@ -1876,7 +1935,7 @@ public void testMaybeBuildUpdatedDocument() throws IOException { ) ); - final boolean changeUserRoles = randomBoolean(); + final boolean changeUserRoles = type != ApiKey.Type.CROSS_CLUSTER && randomBoolean(); final boolean changeKeyRoles = randomBoolean(); final boolean changeMetadata = randomBoolean(); final boolean changeVersion = randomBoolean(); @@ -1884,9 +1943,22 @@ public void testMaybeBuildUpdatedDocument() throws IOException { final Set newUserRoles = changeUserRoles ? randomValueOtherThan(oldUserRoles, () -> randomSet(0, 3, RoleDescriptorTests::randomRoleDescriptor)) : oldUserRoles; - final List newKeyRoles = changeKeyRoles - ? randomValueOtherThan(oldKeyRoles, () -> randomList(0, 3, RoleDescriptorTests::randomRoleDescriptor)) - : (randomBoolean() ? oldKeyRoles : null); + final List newKeyRoles; + if (changeKeyRoles) { + if (type == ApiKey.Type.CROSS_CLUSTER) { + newKeyRoles = randomValueOtherThan(oldKeyRoles, () -> { + try { + return List.of(CrossClusterApiKeyRoleDescriptorBuilder.parse(randomCrossClusterApiKeyAccessField()).build()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } else { + newKeyRoles = randomValueOtherThan(oldKeyRoles, () -> randomList(0, 3, RoleDescriptorTests::randomRoleDescriptor)); + } + } else { + newKeyRoles = randomBoolean() ? oldKeyRoles : null; + } final Map newMetadata = changeMetadata ? randomValueOtherThanMany(md -> md == null || md.equals(oldMetadata), ApiKeyTests::randomMetadata) : (randomBoolean() ? oldMetadata : null); @@ -1901,11 +1973,15 @@ public void testMaybeBuildUpdatedDocument() throws IOException { .build(false) ) : oldAuthentication; - final var request = new UpdateApiKeyRequest(randomAlphaOfLength(10), newKeyRoles, newMetadata); + final String apiKeyId = randomAlphaOfLength(10); + final BaseUpdateApiKeyRequest request = mock(BaseUpdateApiKeyRequest.class); + when(request.getType()).thenReturn(type); + when(request.getRoleDescriptors()).thenReturn(newKeyRoles); + when(request.getMetadata()).thenReturn(newMetadata); final var service = createApiKeyService(); final XContentBuilder builder = service.maybeBuildUpdatedDocument( - request.getId(), + apiKeyId, oldApiKeyDoc, newVersion, newAuthentication, @@ -1921,6 +1997,7 @@ public void testMaybeBuildUpdatedDocument() throws IOException { XContentHelper.createParser(XContentParserConfiguration.EMPTY, BytesReference.bytes(builder), XContentType.JSON) ); assertEquals(oldApiKeyDoc.docType, updatedApiKeyDoc.docType); + assertEquals(oldApiKeyDoc.type, updatedApiKeyDoc.type); assertEquals(oldApiKeyDoc.name, updatedApiKeyDoc.name); assertEquals(oldApiKeyDoc.hash, updatedApiKeyDoc.hash); assertEquals(oldApiKeyDoc.expirationTime, updatedApiKeyDoc.expirationTime); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyActionTests.java new file mode 100644 index 0000000000000..fcedb5fa5e6da --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyActionTests.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.rest.action.apikey; + +import org.elasticsearch.client.internal.node.NodeClient; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestChannel; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.xcontent.NamedXContentRegistry; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequestTests.randomCrossClusterApiKeyAccessField; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class RestUpdateCrossClusterApiKeyActionTests extends ESTestCase { + + public void testUpdateHasTypeOfCrossCluster() throws Exception { + final String id = randomAlphaOfLength(10); + final String access = randomCrossClusterApiKeyAccessField(); + final boolean hasMetadata = randomBoolean(); + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent( + new BytesArray(Strings.format(""" + { + "access": %s%s + }""", access, hasMetadata ? ", \"metadata\":{\"key\":\"value\"}" : "")), + XContentType.JSON + ).withParams(Map.of("id", id)).build(); + + final var action = new RestUpdateCrossClusterApiKeyAction(Settings.EMPTY, mock(XPackLicenseState.class)); + final NodeClient client = mock(NodeClient.class); + action.handleRequest(restRequest, mock(RestChannel.class), client); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass( + UpdateCrossClusterApiKeyRequest.class + ); + verify(client).execute(eq(UpdateCrossClusterApiKeyAction.INSTANCE), requestCaptor.capture(), any()); + + final UpdateCrossClusterApiKeyRequest request = requestCaptor.getValue(); + assertThat(request.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(request.getId(), equalTo(id)); + assertThat(request.getRoleDescriptors(), equalTo(List.of(CrossClusterApiKeyRoleDescriptorBuilder.parse(access).build()))); + if (hasMetadata) { + assertThat(request.getMetadata(), equalTo(Map.of("key", "value"))); + } else { + assertThat(request.getMetadata(), nullValue()); + } + } +}