diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/AbstractCreateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/AbstractCreateApiKeyRequest.java new file mode 100644 index 0000000000000..38587d67ae708 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/AbstractCreateApiKeyRequest.java @@ -0,0 +1,99 @@ +/* + * 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.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.core.TimeValue; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.support.MetadataUtils; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public abstract class AbstractCreateApiKeyRequest extends ActionRequest { + public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL; + protected final String id; + protected String name; + protected TimeValue expiration; + protected Map metadata; + protected List roleDescriptors = Collections.emptyList(); + protected WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY; + + public AbstractCreateApiKeyRequest() { + super(); + // we generate the API key id soonest so it's part of the request body so it is audited + this.id = UUIDs.base64UUID(); // because auditing can currently only catch requests but not responses, + } + + public AbstractCreateApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.id = doReadId(in); + } + + protected abstract String doReadId(StreamInput in) throws IOException; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public abstract ApiKey.Type getType(); + + public TimeValue getExpiration() { + return expiration; + } + + public List getRoleDescriptors() { + return roleDescriptors; + } + + public WriteRequest.RefreshPolicy getRefreshPolicy() { + return refreshPolicy; + } + + public Map getMetadata() { + return metadata; + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(name)) { + validationException = addValidationError("api key name is required", validationException); + } else { + if (name.length() > 256) { + validationException = addValidationError("api key name may not be more than 256 characters long", validationException); + } + if (name.equals(name.trim()) == false) { + validationException = addValidationError("api key name may not begin or end with whitespace", validationException); + } + if (name.startsWith("_")) { + validationException = addValidationError("api key name may not begin with an underscore", validationException); + } + } + if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) { + validationException = addValidationError( + "API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]", + validationException + ); + } + return validationException; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKey.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKey.java index 4be0988081bab..046c4baf9ade3 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKey.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKey.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -36,6 +37,29 @@ */ public final class ApiKey implements ToXContentObject, Writeable { + public enum Type { + /** + * REST type API keys can authenticate on the HTTP interface + */ + REST, + /** + * Cross cluster type API keys can authenticate on the dedicated remote cluster server interface + */ + CROSS_CLUSTER; + + public static Type parse(String value) { + return switch (value.toLowerCase(Locale.ROOT)) { + case "rest" -> REST; + case "cross_cluster" -> CROSS_CLUSTER; + default -> throw new IllegalArgumentException("unknown API key type [" + value + "]"); + }; + } + + public String value() { + return name().toLowerCase(Locale.ROOT); + } + } + private final String name; private final String id; private final Instant creation; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateApiKeyRequest.java index 69ad826c933e4..889257b1b1c0a 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateApiKeyRequest.java @@ -8,10 +8,8 @@ package org.elasticsearch.xpack.core.security.action.apikey; import org.elasticsearch.TransportVersion; -import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.support.WriteRequest; -import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; @@ -19,34 +17,20 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; -import org.elasticsearch.xpack.core.security.support.MetadataUtils; import java.io.IOException; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; -import static org.elasticsearch.action.ValidateActions.addValidationError; - /** * Request class used for the creation of an API key. The request requires a name to be provided * and optionally an expiration time and permission limitation can be provided. */ -public final class CreateApiKeyRequest extends ActionRequest { - public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL; - - private final String id; - private String name; - private TimeValue expiration; - private Map metadata; - private List roleDescriptors = Collections.emptyList(); - private WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY; +public final class CreateApiKeyRequest extends AbstractCreateApiKeyRequest { public CreateApiKeyRequest() { super(); - this.id = UUIDs.base64UUID(); // because auditing can currently only catch requests but not responses, - // we generate the API key id soonest so it's part of the request body so it is audited } /** @@ -74,11 +58,6 @@ public CreateApiKeyRequest( public CreateApiKeyRequest(StreamInput in) throws IOException { super(in); - if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) { - this.id = in.readString(); - } else { - this.id = UUIDs.base64UUID(); - } if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) { this.name = in.readOptionalString(); } else { @@ -94,77 +73,48 @@ public CreateApiKeyRequest(StreamInput in) throws IOException { } } - public String getId() { - return id; + @Override + protected String doReadId(StreamInput in) throws IOException { + if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) { + return in.readString(); + } else { + return UUIDs.base64UUID(); + } } - public void setId() { - throw new UnsupportedOperationException("The API Key Id cannot be set, it must be generated randomly"); + @Override + public ApiKey.Type getType() { + return ApiKey.Type.REST; } - public String getName() { - return name; + public void setId() { + throw new UnsupportedOperationException("The API Key Id cannot be set, it must be generated randomly"); } public void setName(String name) { this.name = name; } - public TimeValue getExpiration() { - return expiration; - } - public void setExpiration(@Nullable TimeValue expiration) { this.expiration = expiration; } - public List getRoleDescriptors() { - return roleDescriptors; - } - public void setRoleDescriptors(@Nullable List roleDescriptors) { this.roleDescriptors = (roleDescriptors == null) ? List.of() : List.copyOf(roleDescriptors); } - public WriteRequest.RefreshPolicy getRefreshPolicy() { - return refreshPolicy; - } - public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); } - public Map getMetadata() { - return metadata; - } - public void setMetadata(Map metadata) { this.metadata = metadata; } @Override public ActionRequestValidationException validate() { - ActionRequestValidationException validationException = null; - if (Strings.isNullOrEmpty(name)) { - validationException = addValidationError("api key name is required", validationException); - } else { - if (name.length() > 256) { - validationException = addValidationError("api key name may not be more than 256 characters long", validationException); - } - if (name.equals(name.trim()) == false) { - validationException = addValidationError("api key name may not begin or end with whitespace", validationException); - } - if (name.startsWith("_")) { - validationException = addValidationError("api key name may not begin with an underscore", validationException); - } - } - if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) { - validationException = addValidationError( - "API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]", - validationException - ); - } - for (RoleDescriptor roleDescriptor : roleDescriptors) { + ActionRequestValidationException validationException = super.validate(); + for (RoleDescriptor roleDescriptor : getRoleDescriptors()) { validationException = RoleDescriptorRequestValidator.validate(roleDescriptor, validationException); } return validationException; @@ -182,7 +132,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeString(name); } out.writeOptionalTimeValue(expiration); - out.writeList(roleDescriptors); + out.writeList(getRoleDescriptors()); refreshPolicy.writeTo(out); if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_13_0)) { out.writeGenericMap(metadata); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..d5bd7f4e6c02e --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyAction.java @@ -0,0 +1,24 @@ +/* + * 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; + +/** + * ActionType for the creation of a cross-cluster API key + */ +public final class CreateCrossClusterApiKeyAction extends ActionType { + + public static final String NAME = "cluster:admin/xpack/security/cross_cluster/api_key/create"; + public static final CreateCrossClusterApiKeyAction INSTANCE = new CreateCrossClusterApiKeyAction(); + + private CreateCrossClusterApiKeyAction() { + super(NAME, CreateApiKeyResponse::new); + } + +} 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 new file mode 100644 index 0000000000000..12be4c833efb0 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequest.java @@ -0,0 +1,98 @@ +/* + * 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.action.support.WriteRequest; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Assertions; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.core.TimeValue; +import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class CreateCrossClusterApiKeyRequest extends AbstractCreateApiKeyRequest { + + public CreateCrossClusterApiKeyRequest( + String name, + CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder, + @Nullable TimeValue expiration, + @Nullable Map metadata + ) { + super(); + this.name = Objects.requireNonNull(name); + this.roleDescriptors = List.of(roleDescriptorBuilder.build()); + this.expiration = expiration; + this.metadata = metadata; + } + + public CreateCrossClusterApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.name = in.readString(); + this.expiration = in.readOptionalTimeValue(); + this.roleDescriptors = in.readImmutableList(RoleDescriptor::new); + this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); + this.metadata = in.readMap(); + } + + @Override + protected String doReadId(StreamInput in) throws IOException { + return in.readString(); + } + + @Override + public ApiKey.Type getType() { + return ApiKey.Type.CROSS_CLUSTER; + } + + @Override + public ActionRequestValidationException validate() { + if (Assertions.ENABLED) { + assert roleDescriptors.size() == 1; + final RoleDescriptor roleDescriptor = roleDescriptors.iterator().next(); + CrossClusterApiKeyRoleDescriptorBuilder.validate(roleDescriptor); + assert RoleDescriptorRequestValidator.validate(roleDescriptor) == null; + } + return super.validate(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(id); + out.writeString(name); + out.writeOptionalTimeValue(expiration); + out.writeList(roleDescriptors); + refreshPolicy.writeTo(out); + out.writeGenericMap(metadata); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CreateCrossClusterApiKeyRequest that = (CreateCrossClusterApiKeyRequest) o; + return Objects.equals(id, that.id) + && Objects.equals(name, that.name) + && Objects.equals(expiration, that.expiration) + && Objects.equals(metadata, that.metadata) + && Objects.equals(roleDescriptors, that.roleDescriptors) + && refreshPolicy == that.refreshPolicy; + } + + @Override + public int hashCode() { + return Objects.hash(id, name, expiration, metadata, roleDescriptors, refreshPolicy); + } +} 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 new file mode 100644 index 0000000000000..13a5aa141579c --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java @@ -0,0 +1,130 @@ +/* + * 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.common.Strings; +import org.elasticsearch.common.util.CollectionUtils; +import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.util.Arrays; +import java.util.List; + +import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public class CrossClusterApiKeyRoleDescriptorBuilder { + + private static final String[] CCS_CLUSTER_PRIVILEGE_NAMES = { "cross_cluster_search" }; + private static final String[] CCR_CLUSTER_PRIVILEGE_NAMES = { "cross_cluster_replication" }; + private static final String[] CCS_AND_CCR_CLUSTER_PRIVILEGE_NAMES = { "cross_cluster_search", "cross_cluster_replication" }; + private static final String[] CCS_INDICES_PRIVILEGE_NAMES = { "read", "read_cross_cluster", "view_index_metadata" }; + private static final String[] CCR_INDICES_PRIVILEGE_NAMES = { "cross_cluster_replication", "cross_cluster_replication_internal" }; + private static final String ROLE_DESCRIPTOR_NAME = "cross_cluster"; + + @SuppressWarnings("unchecked") + public static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "cross_cluster_api_key_request_access", + false, + (args, v) -> new CrossClusterApiKeyRoleDescriptorBuilder( + (List) args[0], + (List) args[1] + ) + ); + + static { + PARSER.declareObjectArray( + optionalConstructorArg(), + (p, c) -> RoleDescriptor.parseIndexWithPredefinedPrivileges(ROLE_DESCRIPTOR_NAME, CCS_INDICES_PRIVILEGE_NAMES, p), + new ParseField("search") + ); + PARSER.declareObjectArray( + optionalConstructorArg(), + (p, c) -> RoleDescriptor.parseIndexWithPredefinedPrivileges(ROLE_DESCRIPTOR_NAME, CCR_INDICES_PRIVILEGE_NAMES, p), + new ParseField("replication") + ); + } + + private final List search; + private final List replication; + + private CrossClusterApiKeyRoleDescriptorBuilder( + List search, + List replication + ) { + this.search = search == null ? List.of() : search; + this.replication = replication == null ? List.of() : replication; + assert this.search.stream().allMatch(p -> Arrays.equals(p.getPrivileges(), CCS_INDICES_PRIVILEGE_NAMES)); + assert this.replication.stream().allMatch(p -> Arrays.equals(p.getPrivileges(), CCR_INDICES_PRIVILEGE_NAMES)); + } + + public RoleDescriptor build() { + final String[] clusterPrivileges; + if (search.isEmpty() && replication.isEmpty()) { + throw new IllegalArgumentException("must specify non-empty access for either [search] or [replication]"); + } else if (search.isEmpty()) { + clusterPrivileges = CCR_CLUSTER_PRIVILEGE_NAMES; + } else if (replication.isEmpty()) { + clusterPrivileges = CCS_CLUSTER_PRIVILEGE_NAMES; + } else { + clusterPrivileges = CCS_AND_CCR_CLUSTER_PRIVILEGE_NAMES; + } + + if (replication.stream().anyMatch(RoleDescriptor.IndicesPrivileges::isUsingDocumentOrFieldLevelSecurity)) { + throw new IllegalArgumentException("replication does not support document or field level security"); + } + + return new RoleDescriptor( + ROLE_DESCRIPTOR_NAME, + clusterPrivileges, + CollectionUtils.concatLists(search, replication).toArray(RoleDescriptor.IndicesPrivileges[]::new), + null + ); + } + + static void validate(RoleDescriptor roleDescriptor) { + if (false == ROLE_DESCRIPTOR_NAME.equals(roleDescriptor.getName())) { + throw new IllegalArgumentException("invalid role descriptor name [" + roleDescriptor.getName() + "]"); + } + if (roleDescriptor.hasApplicationPrivileges()) { + throw new IllegalArgumentException("application privilege must be empty"); + } + if (roleDescriptor.hasRunAs()) { + throw new IllegalArgumentException("run_as privilege must be empty"); + } + if (roleDescriptor.hasConfigurableClusterPrivileges()) { + throw new IllegalArgumentException("configurable cluster privilege must be empty"); + } + if (roleDescriptor.hasRemoteIndicesPrivileges()) { + throw new IllegalArgumentException("remote indices privileges must be empty"); + } + final String[] clusterPrivileges = roleDescriptor.getClusterPrivileges(); + if (false == Arrays.equals(clusterPrivileges, CCS_CLUSTER_PRIVILEGE_NAMES) + && false == Arrays.equals(clusterPrivileges, CCR_CLUSTER_PRIVILEGE_NAMES) + && false == Arrays.equals(clusterPrivileges, CCS_AND_CCR_CLUSTER_PRIVILEGE_NAMES)) { + throw new IllegalArgumentException( + "invalid cluster privileges: [" + Strings.arrayToCommaDelimitedString(clusterPrivileges) + "]" + ); + } + final RoleDescriptor.IndicesPrivileges[] indicesPrivileges = roleDescriptor.getIndicesPrivileges(); + if (indicesPrivileges.length == 0) { + throw new IllegalArgumentException("indices privileges must not be empty"); + } + + for (RoleDescriptor.IndicesPrivileges indexPrivilege : indicesPrivileges) { + final String[] privileges = indexPrivilege.getPrivileges(); + if (Arrays.equals(privileges, CCR_INDICES_PRIVILEGE_NAMES)) { + if (indexPrivilege.isUsingDocumentOrFieldLevelSecurity()) { + throw new IllegalArgumentException("replication does not support document or field level security"); + } + } else if (false == Arrays.equals(privileges, CCS_INDICES_PRIVILEGE_NAMES)) { + throw new IllegalArgumentException("invalid indices privileges: [" + Strings.arrayToCommaDelimitedString(privileges)); + } + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java index 65998dba9fe90..e61f2594ffe00 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java @@ -637,12 +637,31 @@ private static RemoteIndicesPrivileges parseRemoteIndex(String roleName, XConten private record IndicesPrivilegesWithOptionalRemoteClusters(IndicesPrivileges indicesPrivileges, String[] remoteClusters) {} + public static IndicesPrivileges parseIndexWithPredefinedPrivileges(final String roleName, String[] privileges, XContentParser parser) + throws IOException { + final IndicesPrivilegesWithOptionalRemoteClusters indicesPrivilegesWithOptionalRemoteClusters = + parseIndexWithOptionalRemoteClusters(roleName, parser, false, false, privileges); + assert indicesPrivilegesWithOptionalRemoteClusters.remoteClusters == null; + return indicesPrivilegesWithOptionalRemoteClusters.indicesPrivileges; + } + private static IndicesPrivilegesWithOptionalRemoteClusters parseIndexWithOptionalRemoteClusters( final String roleName, final XContentParser parser, final boolean allow2xFormat, final boolean allowRemoteClusters ) throws IOException { + return parseIndexWithOptionalRemoteClusters(roleName, parser, allow2xFormat, allowRemoteClusters, null); + } + + private static IndicesPrivilegesWithOptionalRemoteClusters parseIndexWithOptionalRemoteClusters( + final String roleName, + final XContentParser parser, + final boolean allow2xFormat, + final boolean allowRemoteClusters, + final String[] predefinedPrivileges + ) throws IOException { + assert predefinedPrivileges == null || predefinedPrivileges.length != 0; XContentParser.Token token = parser.currentToken(); if (token != XContentParser.Token.START_OBJECT) { throw new ElasticsearchParseException( @@ -656,7 +675,7 @@ private static IndicesPrivilegesWithOptionalRemoteClusters parseIndexWithOptiona String currentFieldName = null; String[] names = null; BytesReference query = null; - String[] privileges = null; + String[] privileges = predefinedPrivileges; String[] grantedFields = null; String[] deniedFields = null; boolean allowRestrictedIndices = false; @@ -780,7 +799,15 @@ private static IndicesPrivilegesWithOptionalRemoteClusters parseIndexWithOptiona ); } } else if (Fields.PRIVILEGES.match(currentFieldName, parser.getDeprecationHandler())) { - privileges = readStringArray(roleName, parser, true); + if (privileges == null) { + privileges = readStringArray(roleName, parser, true); + } else { + throw new ElasticsearchParseException( + "failed to parse indices privileges for role [{}]. field [{}] must not present", + roleName, + Fields.PRIVILEGES.getPreferredName() + ); + } } else if (Fields.FIELD_PERMISSIONS_2X.match(currentFieldName, parser.getDeprecationHandler())) { if (allow2xFormat) { grantedFields = readStringArray(roleName, parser, true); @@ -1133,6 +1160,10 @@ public boolean isUsingFieldLevelSecurity() { return hasDeniedFields() || hasGrantedFields(); } + public boolean isUsingDocumentOrFieldLevelSecurity() { + return isUsingDocumentLevelSecurity() || isUsingFieldLevelSecurity(); + } + public boolean allowRestrictedIndices() { return allowRestrictedIndices; } 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 new file mode 100644 index 0000000000000..eb2e4b4a8a300 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CreateCrossClusterApiKeyRequestTests.java @@ -0,0 +1,139 @@ +/* + * 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.common.io.stream.Writeable; +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); + } + + @Override + protected Writeable.Reader instanceReader() { + return CreateCrossClusterApiKeyRequest::new; + } + + @Override + protected CreateCrossClusterApiKeyRequest createTestInstance() { + return new CreateCrossClusterApiKeyRequest( + randomAlphaOfLengthBetween(3, 8), + roleDescriptorBuilder, + randomExpiration(), + randomMetadata() + ); + } + + @Override + protected CreateCrossClusterApiKeyRequest mutateInstance(CreateCrossClusterApiKeyRequest instance) throws IOException { + switch (randomIntBetween(1, 4)) { + case 1 -> { + return new CreateCrossClusterApiKeyRequest( + randomValueOtherThan(instance.getName(), () -> randomAlphaOfLengthBetween(3, 8)), + roleDescriptorBuilder, + instance.getExpiration(), + instance.getMetadata() + ); + } + case 2 -> { + return new CreateCrossClusterApiKeyRequest( + instance.getName(), + parseForCrossClusterApiKeyRoleDescriptorBuilder(randomValueOtherThan(access, () -> randomFrom(ACCESS_CANDIDATES))), + instance.getExpiration(), + instance.getMetadata() + ); + } + case 3 -> { + return new CreateCrossClusterApiKeyRequest( + instance.getName(), + roleDescriptorBuilder, + randomValueOtherThan(instance.getExpiration(), CreateCrossClusterApiKeyRequestTests::randomExpiration), + instance.getMetadata() + ); + } + default -> { + return new CreateCrossClusterApiKeyRequest( + instance.getName(), + roleDescriptorBuilder, + instance.getExpiration(), + randomValueOtherThan(instance.getMetadata(), CreateCrossClusterApiKeyRequestTests::randomMetadata) + ); + } + } + } + + 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); + } + + private static Map randomMetadata() { + return randomFrom( + randomMap( + 0, + 3, + () -> new Tuple<>( + randomAlphaOfLengthBetween(3, 8), + randomFrom(randomAlphaOfLengthBetween(3, 8), randomInt(), randomBoolean()) + ) + ), + null + ); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilderTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilderTests.java new file mode 100644 index 0000000000000..e03ec6fa083eb --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilderTests.java @@ -0,0 +1,173 @@ +/* + * 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.ElasticsearchParseException; +import org.elasticsearch.core.Strings; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xcontent.XContentParseException; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.json.JsonXContent; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; + +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +public class CrossClusterApiKeyRoleDescriptorBuilderTests extends ESTestCase { + + public void testBuildForSearchOnly() throws IOException { + final CrossClusterApiKeyRoleDescriptorBuilder access = parseForAccess(""" + { + "search": [ + { + "names": ["metrics"] + } + ] + }"""); + + final RoleDescriptor roleDescriptor = access.build(); + + assertRoleDescriptor( + roleDescriptor, + new String[] { "cross_cluster_search" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("metrics") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .build() } + ); + } + + public void testBuildForReplicationOnly() throws IOException { + final CrossClusterApiKeyRoleDescriptorBuilder access = parseForAccess(""" + { + "replication": [ + { + "names": ["archive"] + } + ] + }"""); + + final RoleDescriptor roleDescriptor = access.build(); + + assertRoleDescriptor( + roleDescriptor, + new String[] { "cross_cluster_replication" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("archive") + .privileges("cross_cluster_replication", "cross_cluster_replication_internal") + .build() } + ); + } + + public void testBuildForSearchAndReplication() throws IOException { + final CrossClusterApiKeyRoleDescriptorBuilder access = parseForAccess(""" + { + "search": [ + { + "names": ["metrics"], + "query": {"term":{"tag":42}} + }, + { + "names": ["logs"], + "field_security": { + "grant": ["*"], + "except": ["private"] + } + } + ], + "replication": [ + { + "names": [ "archive" ], + "allow_restricted_indices": true + } + ] + }"""); + + final RoleDescriptor roleDescriptor = access.build(); + + assertRoleDescriptor( + roleDescriptor, + new String[] { "cross_cluster_search", "cross_cluster_replication" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("metrics") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .query("{\"term\":{\"tag\":42}}") + .build(), + RoleDescriptor.IndicesPrivileges.builder() + .indices("logs") + .privileges("read", "read_cross_cluster", "view_index_metadata") + .grantedFields("*") + .deniedFields("private") + .build(), + RoleDescriptor.IndicesPrivileges.builder() + .indices("archive") + .privileges("cross_cluster_replication", "cross_cluster_replication_internal") + .allowRestrictedIndices(true) + .build() } + ); + } + + public void testExplicitlySpecifyingPrivilegesIsNotAllowed() { + final XContentParseException e = expectThrows(XContentParseException.class, () -> parseForAccess(Strings.format(""" + { + "%s": [ + { + "names": ["metrics"], + "privileges": ["read"] + } + ] + }""", randomFrom("search", "replication")))); + + final Throwable cause = e.getCause(); + assertThat(cause, instanceOf(ElasticsearchParseException.class)); + assertThat( + cause.getMessage(), + containsString("failed to parse indices privileges for role [cross_cluster]. field [privileges] must not present") + ); + } + + public void testEmptyAccessIsNotAllowed() throws IOException { + final CrossClusterApiKeyRoleDescriptorBuilder access1 = parseForAccess( + randomFrom("{}", "{\"search\":[]}", "{\"replication\":[]}", "{\"search\":[],\"replication\":[]}") + ); + final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, access1::build); + assertThat(e1.getMessage(), containsString("must specify non-empty access for either [search] or [replication]")); + + final XContentParseException e2 = expectThrows( + XContentParseException.class, + () -> parseForAccess(randomFrom("{\"search\":null}", "{\"replication\":null}", "{\"search\":null,\"replication\":null}")) + ); + assertThat(e2.getMessage(), containsString("doesn't support values of type: VALUE_NULL")); + } + + private static void assertRoleDescriptor( + RoleDescriptor roleDescriptor, + String[] clusterPrivileges, + RoleDescriptor.IndicesPrivileges[] indicesPrivileges + ) { + assertThat(roleDescriptor.getName().equals("cross_cluster"), is(true)); + assertThat(roleDescriptor.getClusterPrivileges(), arrayContainingInAnyOrder(clusterPrivileges)); + assertThat(roleDescriptor.getIndicesPrivileges(), equalTo(indicesPrivileges)); + CrossClusterApiKeyRoleDescriptorBuilder.validate(roleDescriptor); + } + + private static CrossClusterApiKeyRoleDescriptorBuilder parseForAccess(String content) throws IOException { + return CrossClusterApiKeyRoleDescriptorBuilder.PARSER.parse( + JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, content), + null + ); + } +} 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 b902889ad4eeb..37879a113a416 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 @@ -194,6 +194,7 @@ 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", "cluster:admin/xpack/security/delegate_pki", "cluster:admin/xpack/security/enroll/node", "cluster:admin/xpack/security/enroll/kibana", 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 e500ed5ab3c81..5298eff047430 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 @@ -67,6 +67,8 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase { private static final SecureString END_USER_PASSWORD = new SecureString("end-user-password".toCharArray()); private static final String MANAGE_OWN_API_KEY_USER = "manage_own_api_key_user"; private static final String REMOTE_INDICES_USER = "remote_indices_user"; + private static final String MANAGE_API_KEY_USER = "manage_api_key_user"; + private static final String MANAGE_SECURITY_USER = "manage_security_user"; @Before public void createUsers() throws IOException { @@ -76,6 +78,10 @@ public void createUsers() throws IOException { createRole("user_role", Set.of("monitor")); createUser(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD, List.of("manage_own_api_key_role")); createRole("manage_own_api_key_role", Set.of("manage_own_api_key")); + createUser(MANAGE_API_KEY_USER, END_USER_PASSWORD, List.of("manage_api_key_role")); + createRole("manage_api_key_role", Set.of("manage_api_key")); + createUser(MANAGE_SECURITY_USER, END_USER_PASSWORD, List.of("manage_security_role")); + createRole("manage_security_role", Set.of("manage_security")); } @After @@ -83,11 +89,17 @@ public void cleanUp() throws IOException { deleteUser(SYSTEM_USER); deleteUser(END_USER); deleteUser(MANAGE_OWN_API_KEY_USER); + deleteUser(MANAGE_API_KEY_USER); + deleteUser(MANAGE_SECURITY_USER); deleteRole("system_role"); deleteRole("user_role"); deleteRole("manage_own_api_key_role"); + deleteRole("manage_api_key_role"); + deleteRole("manage_security_role"); invalidateApiKeysForUser(END_USER); invalidateApiKeysForUser(MANAGE_OWN_API_KEY_USER); + invalidateApiKeysForUser(MANAGE_API_KEY_USER); + invalidateApiKeysForUser(MANAGE_SECURITY_USER); } @SuppressWarnings("unchecked") @@ -668,6 +680,179 @@ public void testRemoteIndicesSupportForApiKeys() throws IOException { } + public void testCreateCrossClusterApiKey() 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": "my-key", + "access": { + "search": [ + { + "names": [ "metrics" ], + "query": "{\\"term\\":{\\"score\\":42}}" + } + ], + "replication": [ + { + "names": [ "logs" ], + "allow_restricted_indices": true + } + ] + }, + "expiration": "7d", + "metadata": { "tag": "shared", "points": 0 } + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + final ObjectPath createResponse = assertOKAndCreateObjectPath(client().performRequest(createRequest)); + final String apiKeyId = createResponse.evaluate("id"); + + final Request fetchRequest; + if (randomBoolean()) { + fetchRequest = new Request("GET", "/_security/api_key"); + fetchRequest.addParameter("id", apiKeyId); + } else { + fetchRequest = new Request("GET", "/_security/_query/api_key"); + 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.role_descriptors"), + equalTo( + Map.of( + "cross_cluster", + XContentTestUtils.convertToMap( + new RoleDescriptor( + "cross_cluster", + new String[] { "cross_cluster_search", "cross_cluster_replication" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .indices("metrics") + .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") + .allowRestrictedIndices(true) + .build() }, + null + ) + ) + ) + ) + ); + + final Request deleteRequest = new Request("DELETE", "/_security/api_key"); + deleteRequest.setJsonEntity(Strings.format(""" + {"ids": ["%s"]}""", apiKeyId)); + if (randomBoolean()) { + setUserForRequest(deleteRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + } else { + setUserForRequest(deleteRequest, MANAGE_API_KEY_USER, END_USER_PASSWORD); + } + final ObjectPath deleteResponse = assertOKAndCreateObjectPath(client().performRequest(deleteRequest)); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), equalTo(List.of(apiKeyId))); + + // Cannot create cross-cluster API keys with either manage_api_key or manage_own_api_key privilege + if (randomBoolean()) { + setUserForRequest(createRequest, MANAGE_API_KEY_USER, END_USER_PASSWORD); + } else { + setUserForRequest(createRequest, MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD); + } + final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(createRequest)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(e.getMessage(), containsString("action [cluster:admin/xpack/security/cross_cluster/api_key/create] is unauthorized")); + } + + public void testCrossClusterApiKeyDoesNotAllowEmptyAccess() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + + assertBadCreateCrossClusterApiKeyRequest(""" + {"name": "my-key"}""", "Required [access]"); + + assertBadCreateCrossClusterApiKeyRequest(""" + {"name": "my-key", "access": null}""", "access doesn't support values of type: VALUE_NULL"); + + assertBadCreateCrossClusterApiKeyRequest(""" + {"name": "my-key", "access": {}}}""", "must specify non-empty access for either [search] or [replication]"); + + assertBadCreateCrossClusterApiKeyRequest(""" + {"name": "my-key", "access": {"search":[]}}}""", "must specify non-empty access for either [search] or [replication]"); + + assertBadCreateCrossClusterApiKeyRequest(""" + {"name": "my-key", "access": {"replication":[]}}}""", "must specify non-empty access for either [search] or [replication]"); + + assertBadCreateCrossClusterApiKeyRequest( + """ + {"name": "my-key", "access": {"search":[],"replication":[]}}}""", + "must specify non-empty access for either [search] or [replication]" + ); + } + + public void testCrossClusterApiKeyDoesNotAllowDlsFlsForReplication() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + + assertBadCreateCrossClusterApiKeyRequest(""" + { + "name": "key", + "access": { + "replication": [ {"names": ["logs"], "query":{"term": {"tag": 42}}} ] + } + }""", "replication does not support document or field level security"); + + assertBadCreateCrossClusterApiKeyRequest(""" + { + "name": "key", + "access": { + "replication": [ {"names": ["logs"], "field_security": {"grant": ["*"], "except": ["private"]}} ] + } + }""", "replication does not support document or field level security"); + + assertBadCreateCrossClusterApiKeyRequest(""" + { + "name": "key", + "access": { + "replication": [ { + "names": ["logs"], + "query": {"term": {"tag": 42}}, + "field_security": {"grant": ["*"], "except": ["private"]} + } ] + } + }""", "replication does not support document or field level security"); + } + + public void testCrossClusterApiKeyRequiresName() throws IOException { + assumeTrue("untrusted remote cluster feature flag must be enabled", TcpTransport.isUntrustedRemoteClusterEnabled()); + + assertBadCreateCrossClusterApiKeyRequest(""" + { + "access": { + "search": [ {"names": ["logs"]} ] + } + }""", "Required [name]"); + } + + private void assertBadCreateCrossClusterApiKeyRequest(String body, String expectedErrorMessage) throws IOException { + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(body); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(createRequest)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e.getMessage(), containsString(expectedErrorMessage)); + } + private Response sendRequestWithRemoteIndices(final Request request, final boolean executeAsRemoteIndicesUser) throws IOException { if (executeAsRemoteIndicesUser) { request.setOptions( @@ -788,6 +973,15 @@ private EncodedApiKey createApiKey(final String apiKeyName, final Map future = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + final CreateApiKeyResponse createApiKeyResponse = future.actionGet(); + + final Map document = client().execute( + GetAction.INSTANCE, + new GetRequest(SECURITY_MAIN_ALIAS, createApiKeyResponse.getId()) + ).actionGet().getSource(); + + assertThat(document.get("type"), equalTo("cross_cluster")); + + @SuppressWarnings("unchecked") + final Map roleDescriptors = (Map) document.get("role_descriptors"); + assertThat(roleDescriptors.keySet(), contains("cross_cluster")); + @SuppressWarnings("unchecked") + final RoleDescriptor actualRoleDescriptor = RoleDescriptor.parse( + "cross_cluster", + XContentTestUtils.convertToXContent((Map) roleDescriptors.get("cross_cluster"), XContentType.JSON), + false, + XContentType.JSON + ); + + assertThat( + actualRoleDescriptor, + equalTo( + 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 + ) + ) + ); + assertThat((Map) document.get("limited_by_role_descriptors"), anEmptyMap()); + } + private GrantApiKeyRequest buildGrantApiKeyRequest(String username, SecureString password, String runAsUsername) throws IOException { final SecureString clonedPassword = password.clone(); final GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest(); 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 f8399bc254ae8..0eb3d670e0015 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 @@ -89,6 +89,7 @@ import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.tracing.Tracer; import org.elasticsearch.transport.RemoteClusterService; +import org.elasticsearch.transport.TcpTransport; import org.elasticsearch.transport.Transport; import org.elasticsearch.transport.TransportInterceptor; import org.elasticsearch.transport.TransportRequest; @@ -111,6 +112,7 @@ import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyAction; @@ -194,6 +196,7 @@ import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticationAction; import org.elasticsearch.xpack.security.action.apikey.TransportBulkUpdateApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportCreateApiKeyAction; +import org.elasticsearch.xpack.security.action.apikey.TransportCreateCrossClusterApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportGetApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportGrantApiKeyAction; import org.elasticsearch.xpack.security.action.apikey.TransportInvalidateApiKeyAction; @@ -297,6 +300,7 @@ import org.elasticsearch.xpack.security.rest.action.apikey.RestBulkUpdateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestClearApiKeyCacheAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateApiKeyAction; +import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateCrossClusterApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction; @@ -1270,7 +1274,7 @@ public void onIndexModule(IndexModule module) { return Arrays.asList(usageAction, infoAction); } - return Arrays.asList( + return Stream.of( new ActionHandler<>(ClearRealmCacheAction.INSTANCE, TransportClearRealmCacheAction.class), new ActionHandler<>(ClearRolesCacheAction.INSTANCE, TransportClearRolesCacheAction.class), new ActionHandler<>(ClearPrivilegesCacheAction.INSTANCE, TransportClearPrivilegesCacheAction.class), @@ -1307,6 +1311,9 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class), + TcpTransport.isUntrustedRemoteClusterEnabled() + ? new ActionHandler<>(CreateCrossClusterApiKeyAction.INSTANCE, TransportCreateCrossClusterApiKeyAction.class) + : null, new ActionHandler<>(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class), new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class), new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class), @@ -1329,7 +1336,7 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(SetProfileEnabledAction.INSTANCE, TransportSetProfileEnabledAction.class), usageAction, infoAction - ); + ).filter(Objects::nonNull).toList(); } @Override @@ -1353,7 +1360,7 @@ public List getRestHandlers( if (enabled == false) { return emptyList(); } - return Arrays.asList( + return Stream.of( new RestAuthenticateAction(settings, securityContext.get(), getLicenseState()), new RestClearRealmCacheAction(settings, getLicenseState()), new RestClearRolesCacheAction(settings, getLicenseState()), @@ -1390,6 +1397,7 @@ public List getRestHandlers( new RestPutPrivilegesAction(settings, getLicenseState()), new RestDeletePrivilegesAction(settings, getLicenseState()), new RestCreateApiKeyAction(settings, getLicenseState()), + TcpTransport.isUntrustedRemoteClusterEnabled() ? new RestCreateCrossClusterApiKeyAction(settings, getLicenseState()) : null, new RestUpdateApiKeyAction(settings, getLicenseState()), new RestBulkUpdateApiKeyAction(settings, getLicenseState()), new RestGrantApiKeyAction(settings, getLicenseState()), @@ -1410,7 +1418,7 @@ public List getRestHandlers( new RestSuggestProfilesAction(settings, getLicenseState()), new RestEnableProfileAction(settings, getLicenseState()), new RestDisableProfileAction(settings, getLicenseState()) - ); + ).filter(Objects::nonNull).toList(); } @Override diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..2c0df3cd59dfc --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyAction.java @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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.HandledTransportAction; +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.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.authc.Authentication; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +import java.util.Set; + +/** + * Implementation of the action needed to create an API key + */ +public final class TransportCreateCrossClusterApiKeyAction extends HandledTransportAction< + CreateCrossClusterApiKeyRequest, + CreateApiKeyResponse> { + + private final ApiKeyService apiKeyService; + private final SecurityContext securityContext; + + @Inject + public TransportCreateCrossClusterApiKeyAction( + TransportService transportService, + ActionFilters actionFilters, + ApiKeyService apiKeyService, + SecurityContext context + ) { + super(CreateCrossClusterApiKeyAction.NAME, transportService, actionFilters, CreateCrossClusterApiKeyRequest::new); + this.apiKeyService = apiKeyService; + this.securityContext = context; + } + + @Override + protected void doExecute(Task task, CreateCrossClusterApiKeyRequest request, ActionListener listener) { + final Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + listener.onFailure(new IllegalStateException("authentication is required")); + } else { + apiKeyService.createApiKey(authentication, request, Set.of(), listener); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java index 6091773d8ced9..bc76c8e2c8261 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java @@ -44,6 +44,7 @@ import org.elasticsearch.xcontent.json.JsonXContent; import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.action.Grant; +import org.elasticsearch.xpack.core.security.action.apikey.AbstractCreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BaseUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; @@ -1219,11 +1220,11 @@ LogEntryBuilder withRequestBody(PutPrivilegesRequest putPrivilegesRequest) throw return this; } - LogEntryBuilder withRequestBody(CreateApiKeyRequest createApiKeyRequest) throws IOException { + LogEntryBuilder withRequestBody(AbstractCreateApiKeyRequest abstractCreateApiKeyRequest) throws IOException { logEntry.with(EVENT_ACTION_FIELD_NAME, "create_apikey"); XContentBuilder builder = JsonXContent.contentBuilder().humanReadable(true); builder.startObject(); - withRequestBody(builder, createApiKeyRequest); + withRequestBody(builder, abstractCreateApiKeyRequest); builder.endObject(); logEntry.with(CREATE_CONFIG_FIELD_NAME, Strings.toString(builder)); return this; @@ -1261,19 +1262,19 @@ LogEntryBuilder withRequestBody(final BulkUpdateApiKeyRequest bulkUpdateApiKeyRe return this; } - private void withRequestBody(XContentBuilder builder, CreateApiKeyRequest createApiKeyRequest) throws IOException { - TimeValue expiration = createApiKeyRequest.getExpiration(); + private void withRequestBody(XContentBuilder builder, AbstractCreateApiKeyRequest abstractCreateApiKeyRequest) throws IOException { + TimeValue expiration = abstractCreateApiKeyRequest.getExpiration(); builder.startObject("apikey") - .field("id", createApiKeyRequest.getId()) - .field("name", createApiKeyRequest.getName()) + .field("id", abstractCreateApiKeyRequest.getId()) + .field("name", abstractCreateApiKeyRequest.getName()) .field("expiration", expiration != null ? expiration.toString() : null) .startArray("role_descriptors"); - for (RoleDescriptor roleDescriptor : createApiKeyRequest.getRoleDescriptors()) { + for (RoleDescriptor roleDescriptor : abstractCreateApiKeyRequest.getRoleDescriptors()) { withRoleDescriptor(builder, roleDescriptor); } builder.endArray(); // role_descriptors - if (createApiKeyRequest.getMetadata() != null && createApiKeyRequest.getMetadata().isEmpty() == false) { - builder.field("metadata", createApiKeyRequest.getMetadata()); + if (abstractCreateApiKeyRequest.getMetadata() != null && abstractCreateApiKeyRequest.getMetadata().isEmpty() == false) { + builder.field("metadata", abstractCreateApiKeyRequest.getMetadata()); } builder.endObject(); // apikey } 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 06970a0a0197b..45e038a7e40a9 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 @@ -82,11 +82,11 @@ import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest; 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.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.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse; @@ -137,6 +137,7 @@ import static org.elasticsearch.core.Strings.format; import static org.elasticsearch.search.SearchService.DEFAULT_KEEPALIVE_SETTING; +import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCR; import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCS; import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg; import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; @@ -292,10 +293,11 @@ public void invalidateAll() { */ public void createApiKey( Authentication authentication, - CreateApiKeyRequest request, + AbstractCreateApiKeyRequest request, Set userRoleDescriptors, ActionListener listener ) { + assert request.getType() != ApiKey.Type.CROSS_CLUSTER || userRoleDescriptors.isEmpty(); ensureEnabled(); if (authentication == null) { listener.onFailure(new IllegalArgumentException("authentication must be provided")); @@ -313,6 +315,17 @@ && hasRemoteIndices(request.getRoleDescriptors())) { ); return; } + if (transportVersion.before(TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCR) + && request.getType() == ApiKey.Type.CROSS_CLUSTER) { + listener.onFailure( + new IllegalArgumentException( + "all nodes must have transport version [" + + TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCR + + "] or higher to support creating cross cluster API keys" + ) + ); + return; + } final Set filteredUserRoleDescriptors = maybeRemoveRemoteIndicesPrivileges( userRoleDescriptors, @@ -334,7 +347,7 @@ private static boolean hasRemoteIndices(Collection roleDescripto private void createApiKeyAndIndexIt( Authentication authentication, - CreateApiKeyRequest request, + AbstractCreateApiKeyRequest request, Set userRoleDescriptors, ActionListener listener ) { @@ -353,6 +366,7 @@ private void createApiKeyAndIndexIt( created, expiration, request.getRoleDescriptors(), + request.getType(), version, request.getMetadata() ) @@ -615,12 +629,14 @@ static XContentBuilder newDocument( Instant created, Instant expiration, List keyRoleDescriptors, + ApiKey.Type type, Version version, @Nullable Map metadata ) throws IOException { final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject() .field("doc_type", "api_key") + .field("type", type.value()) .field("creation_time", created.toEpochMilli()) .field("expiration_time", expiration == null ? null : expiration.toEpochMilli()) .field("api_key_invalidated", false); @@ -1167,7 +1183,7 @@ protected void verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credent })); } - private static Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { + private static Instant getApiKeyExpiration(Instant now, AbstractCreateApiKeyRequest request) { if (request.getExpiration() != null) { return now.plusSeconds(request.getExpiration().getSeconds()); } else { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyAction.java new file mode 100644 index 0000000000000..9f003314c7898 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyAction.java @@ -0,0 +1,82 @@ +/* + * 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.core.TimeValue; +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.CreateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.rest.RestRequest.Method.POST; +import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Rest action to create an API key specific to cross cluster access via the dedicate remote cluster server port + */ +public final class RestCreateCrossClusterApiKeyAction extends ApiKeyBaseRestHandler { + + @SuppressWarnings("unchecked") + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "cross_cluster_api_key_request", + false, + (args, v) -> new CreateCrossClusterApiKeyRequest( + (String) args[0], + (CrossClusterApiKeyRoleDescriptorBuilder) args[1], + TimeValue.parseTimeValue((String) args[2], null, "expiration"), + (Map) args[3] + ) + ); + + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareObject(constructorArg(), CrossClusterApiKeyRoleDescriptorBuilder.PARSER, new ParseField("access")); + PARSER.declareString(optionalConstructorArg(), new ParseField("expiration")); + PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); + } + + /** + * @param settings the node's settings + * @param licenseState the license state that will be used to determine if + * security is licensed + */ + public RestCreateCrossClusterApiKeyAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public List routes() { + return List.of(new Route(POST, "/_security/cross_cluster/api_key")); + } + + @Override + public String getName() { + return "xpack_security_create_cross_cluster_api_key"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException { + final CreateCrossClusterApiKeyRequest createCrossClusterApiKeyRequest = PARSER.parse(request.contentParser(), null); + return channel -> client.execute( + CreateCrossClusterApiKeyAction.INSTANCE, + createCrossClusterApiKeyRequest, + new RestToXContentListener<>(channel) + ); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyActionTests.java new file mode 100644 index 0000000000000..26b594bca9a01 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportCreateCrossClusterApiKeyActionTests.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.security.action.apikey; + +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.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; +import org.elasticsearch.xpack.security.authc.ApiKeyService; + +import java.io.IOException; +import java.util.Set; + +import static org.elasticsearch.xcontent.json.JsonXContent.jsonXContent; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TransportCreateCrossClusterApiKeyActionTests extends ESTestCase { + + public void testApiKeyWillBeCreatedWithEmptyUserRoleDescriptors() throws IOException { + final ApiKeyService apiKeyService = mock(ApiKeyService.class); + final SecurityContext securityContext = mock(SecurityContext.class); + final Authentication authentication = AuthenticationTestHelper.builder().build(); + when(securityContext.getAuthentication()).thenReturn(authentication); + final var action = new TransportCreateCrossClusterApiKeyAction( + mock(TransportService.class), + mock(ActionFilters.class), + apiKeyService, + securityContext + ); + + final XContentParser parser = jsonXContent.createParser(XContentParserConfiguration.EMPTY, """ + { + "search": [ {"names": ["idx"]} ] + }"""); + + final CreateCrossClusterApiKeyRequest request = new CreateCrossClusterApiKeyRequest( + randomAlphaOfLengthBetween(3, 8), + CrossClusterApiKeyRoleDescriptorBuilder.PARSER.parse(parser, null), + null, + null + ); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future); + verify(apiKeyService).createApiKey(same(authentication), same(request), eq(Set.of()), same(future)); + } +} 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 96843a521b691..908bdea189f46 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 @@ -36,6 +36,8 @@ import org.elasticsearch.action.update.UpdateRequestBuilder; import org.elasticsearch.action.update.UpdateResponse; import org.elasticsearch.client.internal.Client; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.bytes.BytesReference; @@ -80,6 +82,8 @@ import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest; 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.ApiKeyTests; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyResponse; @@ -723,6 +727,7 @@ private Map mockKeyDocument( Instant.now(), Instant.now().plus(expiry), keyRoles, + randomFrom(ApiKey.Type.values()), Version.CURRENT, metadata ); @@ -1856,6 +1861,7 @@ public void testMaybeBuildUpdatedDocument() throws IOException { Instant.now(), randomBoolean() ? null : Instant.now(), oldKeyRoles, + randomFrom(ApiKey.Type.values()), oldVersion, oldMetadata ) @@ -2115,6 +2121,44 @@ public void testBuildDelimitedStringWithLimit() { assertThat(e.getMessage(), equalTo("limit must be positive number")); } + public void testCreateCrossClusterApiKeyMinVersionConstraint() { + final Authentication authentication = AuthenticationTestHelper.builder().build(); + final AbstractCreateApiKeyRequest request = mock(AbstractCreateApiKeyRequest.class); + when(request.getType()).thenReturn(ApiKey.Type.CROSS_CLUSTER); + + final ClusterService clusterService = mock(ClusterService.class); + when(clusterService.getClusterSettings()).thenReturn( + new ClusterSettings(Settings.EMPTY, Set.of(ApiKeyService.DELETE_RETENTION_PERIOD)) + ); + final ClusterState clusterState = mock(ClusterState.class); + when(clusterService.state()).thenReturn(clusterState); + final TransportVersion minTransportVersion = TransportVersionUtils.randomVersionBetween( + random(), + TransportVersion.MINIMUM_COMPATIBLE, + TransportVersionUtils.getPreviousVersion(TransportVersion.V_8_9_0) + ); + when(clusterState.getMinTransportVersion()).thenReturn(minTransportVersion); + + final ApiKeyService service = new ApiKeyService( + Settings.EMPTY, + clock, + client, + securityIndex, + clusterService, + cacheInvalidatorRegistry, + threadPool + ); + + final PlainActionFuture future = new PlainActionFuture<>(); + service.createApiKey(authentication, request, Set.of(), future); + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, future::actionGet); + + assertThat( + e.getMessage(), + containsString("all nodes must have transport version [8090099] or higher to support creating cross cluster API keys") + ); + } + private static RoleDescriptor randomRoleDescriptorWithRemoteIndexPrivileges() { return new RoleDescriptor( randomAlphaOfLengthBetween(3, 90), @@ -2148,6 +2192,7 @@ public static Authentication createApiKeyAuthentication( Instant.now(), Instant.now().plus(Duration.ofSeconds(3600)), keyRoles, + randomFrom(ApiKey.Type.values()), Version.CURRENT, randomBoolean() ? null : Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)) ); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateApiKeyActionTests.java index b56db1eae23d8..4bc4fd0ecbc85 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateApiKeyActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateApiKeyActionTests.java @@ -28,6 +28,7 @@ import org.elasticsearch.threadpool.ThreadPool; 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.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; @@ -37,6 +38,7 @@ import java.util.UUID; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.mock; public class RestCreateApiKeyActionTests extends ESTestCase { @@ -94,6 +96,7 @@ public void doE CreateApiKeyRequest createApiKeyRequest = (CreateApiKeyRequest) request; @SuppressWarnings("unchecked") RestToXContentListener actionListener = (RestToXContentListener) listener; + assertThat(createApiKeyRequest.getType(), is(ApiKey.Type.REST)); if (createApiKeyRequest.getName().equals("my-api-key")) { actionListener.onResponse(expected); } else { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyActionTests.java new file mode 100644 index 0000000000000..d971e06f09481 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestCreateCrossClusterApiKeyActionTests.java @@ -0,0 +1,83 @@ +/* + * 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.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.CreateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +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 RestCreateCrossClusterApiKeyActionTests extends ESTestCase { + + public void testCreateApiKeyRequestHasTypeOfCrossCluster() throws Exception { + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent(new BytesArray(""" + { + "name": "my-key", + "access": { + "search": [ + { + "names": [ + "logs" + ] + } + ] + } + }"""), XContentType.JSON).build(); + + final var action = new RestCreateCrossClusterApiKeyAction(Settings.EMPTY, mock(XPackLicenseState.class)); + final NodeClient client = mock(NodeClient.class); + action.handleRequest(restRequest, mock(RestChannel.class), client); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass( + CreateCrossClusterApiKeyRequest.class + ); + verify(client).execute(eq(CreateCrossClusterApiKeyAction.INSTANCE), requestCaptor.capture(), any()); + + final CreateCrossClusterApiKeyRequest request = requestCaptor.getValue(); + assertThat(request.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(request.getName(), equalTo("my-key")); + assertThat( + request.getRoleDescriptors(), + equalTo( + List.of( + 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 + ) + ) + ) + ); + assertThat(request.getMetadata(), nullValue()); + } +}