diff --git a/docs/changelog/134604.yaml b/docs/changelog/134604.yaml new file mode 100644 index 0000000000000..a33817fdc29c8 --- /dev/null +++ b/docs/changelog/134604.yaml @@ -0,0 +1,5 @@ +pr: 134604 +summary: Adds certificate identity field to cross-cluster API keys +area: Security +type: enhancement +issues: [] 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 c51d897912fb5..4d9d9e5933584 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 @@ -121,6 +121,8 @@ public VersionId versionNumber() { private final List roleDescriptors; @Nullable private final RoleDescriptorsIntersection limitedBy; + @Nullable + private final String certificateIdentity; public ApiKey( String name, @@ -135,7 +137,8 @@ public ApiKey( @Nullable String realmType, @Nullable Map metadata, @Nullable List roleDescriptors, - @Nullable List limitedByRoleDescriptors + @Nullable List limitedByRoleDescriptors, + @Nullable String certificateIdentity ) { this( name, @@ -150,7 +153,8 @@ public ApiKey( realmType, metadata, roleDescriptors, - limitedByRoleDescriptors == null ? null : new RoleDescriptorsIntersection(List.of(Set.copyOf(limitedByRoleDescriptors))) + limitedByRoleDescriptors == null ? null : new RoleDescriptorsIntersection(List.of(Set.copyOf(limitedByRoleDescriptors))), + certificateIdentity ); } @@ -167,7 +171,8 @@ private ApiKey( @Nullable String realmType, @Nullable Map metadata, @Nullable List roleDescriptors, - @Nullable RoleDescriptorsIntersection limitedBy + @Nullable RoleDescriptorsIntersection limitedBy, + @Nullable String certificateIdentity ) { this.name = name; this.id = id; @@ -187,6 +192,7 @@ private ApiKey( // This assertion will need to be changed (or removed) when derived keys are properly supported assert limitedBy == null || limitedBy.roleDescriptorsList().size() == 1 : "can only have one set of limited-by role descriptors"; this.limitedBy = limitedBy; + this.certificateIdentity = certificateIdentity; } // Should only be used by XContent parsers @@ -205,7 +211,8 @@ private ApiKey( (String) parsed[9], (parsed[10] == null) ? null : (Map) parsed[10], (List) parsed[11], - (RoleDescriptorsIntersection) parsed[12] + (RoleDescriptorsIntersection) parsed[12], + (String) parsed[13] ); } @@ -268,6 +275,10 @@ public RoleDescriptorsIntersection getLimitedBy() { return limitedBy; } + public @Nullable String getCertificateIdentity() { + return certificateIdentity; + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); @@ -306,6 +317,11 @@ public XContentBuilder innerToXContent(XContentBuilder builder, Params params) t assert type != Type.CROSS_CLUSTER; builder.field("limited_by", limitedBy); } + + if (certificateIdentity != null) { + builder.field("certificate_identity", certificateIdentity); + } + return builder; } @@ -357,7 +373,8 @@ public int hashCode() { realmType, metadata, roleDescriptors, - limitedBy + limitedBy, + certificateIdentity ); } @@ -385,7 +402,9 @@ public boolean equals(Object obj) { && Objects.equals(realmType, other.realmType) && Objects.equals(metadata, other.metadata) && Objects.equals(roleDescriptors, other.roleDescriptors) - && Objects.equals(limitedBy, other.limitedBy); + && Objects.equals(limitedBy, other.limitedBy) + && Objects.equals(certificateIdentity, other.certificateIdentity); + } @Override @@ -416,6 +435,8 @@ public String toString() { + roleDescriptors + ", limited_by=" + limitedBy + + ", certificate_identity=" + + certificateIdentity + "]"; } @@ -452,6 +473,8 @@ static int initializeParser(AbstractObjectParser parser) { new ParseField("limited_by"), ObjectParser.ValueType.OBJECT_ARRAY ); - return 13; // the number of fields to parse + parser.declareStringOrNull(optionalConstructorArg(), new ParseField("certificate_identity")); + + return 14; // the number of fields to parse } } 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 index 0ea772920652b..97343c629f69b 100644 --- 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 @@ -26,9 +26,10 @@ public BaseBulkUpdateApiKeyRequest( final List ids, @Nullable final List roleDescriptors, @Nullable final Map metadata, - @Nullable final TimeValue expiration + @Nullable final TimeValue expiration, + @Nullable final CertificateIdentity certificateIdentity ) { - super(roleDescriptors, metadata, expiration); + super(roleDescriptors, metadata, expiration, certificateIdentity); this.ids = Objects.requireNonNull(ids, "API key IDs must not be null"); } @@ -38,6 +39,16 @@ public ActionRequestValidationException validate() { if (ids.isEmpty()) { validationException = addValidationError("Field [ids] cannot be empty", validationException); } + + if (getCertificateIdentity() != null && ids.size() > 1) { + validationException = addValidationError( + "Certificate identity can only be updated for a single API key at a time. Found [" + + ids.size() + + "] API key IDs in the request.", + validationException + ); + } + return validationException; } @@ -54,11 +65,12 @@ public boolean equals(Object o) { return Objects.equals(getIds(), that.getIds()) && Objects.equals(metadata, that.metadata) && Objects.equals(expiration, that.expiration) - && Objects.equals(roleDescriptors, that.roleDescriptors); + && Objects.equals(roleDescriptors, that.roleDescriptors) + && Objects.equals(certificateIdentity, that.certificateIdentity); } @Override public int hashCode() { - return Objects.hash(getIds(), expiration, metadata, roleDescriptors); + return Objects.hash(getIds(), expiration, metadata, roleDescriptors, certificateIdentity); } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseSingleUpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseSingleUpdateApiKeyRequest.java index a3958b31e4716..019cc1e0dc23f 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseSingleUpdateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseSingleUpdateApiKeyRequest.java @@ -23,9 +23,10 @@ public BaseSingleUpdateApiKeyRequest( @Nullable final List roleDescriptors, @Nullable final Map metadata, @Nullable final TimeValue expiration, - String id + String id, + @Nullable CertificateIdentity certificateIdentity ) { - super(roleDescriptors, metadata, expiration); + super(roleDescriptors, metadata, expiration, certificateIdentity); this.id = Objects.requireNonNull(id, "API key ID must not be null"); } @@ -42,11 +43,12 @@ public boolean equals(Object o) { return Objects.equals(getId(), that.getId()) && Objects.equals(metadata, that.metadata) && Objects.equals(expiration, that.expiration) - && Objects.equals(roleDescriptors, that.roleDescriptors); + && Objects.equals(roleDescriptors, that.roleDescriptors) + && Objects.equals(certificateIdentity, that.certificateIdentity); } @Override public int hashCode() { - return Objects.hash(getId(), expiration, metadata, roleDescriptors); + return Objects.hash(getId(), expiration, metadata, roleDescriptors, certificateIdentity); } } 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 9dfd8a9aadb86..8af89b3922ded 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 @@ -31,15 +31,19 @@ public abstract class BaseUpdateApiKeyRequest extends LegacyActionRequest { protected final Map metadata; @Nullable protected final TimeValue expiration; + @Nullable + protected final CertificateIdentity certificateIdentity; public BaseUpdateApiKeyRequest( @Nullable final List roleDescriptors, @Nullable final Map metadata, - @Nullable final TimeValue expiration + @Nullable final TimeValue expiration, + @Nullable final CertificateIdentity certificateIdentity ) { this.roleDescriptors = roleDescriptors; this.metadata = metadata; this.expiration = expiration; + this.certificateIdentity = certificateIdentity; } public Map getMetadata() { @@ -54,6 +58,10 @@ public TimeValue getExpiration() { return expiration; } + public CertificateIdentity getCertificateIdentity() { + return certificateIdentity; + } + public abstract ApiKey.Type getType(); @Override 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 eab74d6250aca..7102e81711ed2 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 @@ -15,7 +15,7 @@ import java.util.List; import java.util.Map; -public final class BulkUpdateApiKeyRequest extends BaseBulkUpdateApiKeyRequest { +public class BulkUpdateApiKeyRequest extends BaseBulkUpdateApiKeyRequest { public static BulkUpdateApiKeyRequest usingApiKeyIds(String... ids) { return new BulkUpdateApiKeyRequest(Arrays.stream(ids).toList(), null, null, null); @@ -36,7 +36,7 @@ public BulkUpdateApiKeyRequest( @Nullable final Map metadata, @Nullable final TimeValue expiration ) { - super(ids, roleDescriptors, metadata, expiration); + super(ids, roleDescriptors, metadata, expiration, null); } @Override diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTranslator.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTranslator.java index d4fdb2d7f1028..d9ed28927ec8a 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTranslator.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTranslator.java @@ -11,6 +11,7 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ObjectParser; import org.elasticsearch.xcontent.ParseField; import org.elasticsearch.xcontent.XContentParser; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -51,6 +52,14 @@ protected static ConstructingObjectParser createP }, new ParseField("role_descriptors")); parser.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); parser.declareString(optionalConstructorArg(), new ParseField("expiration")); + parser.declareField( + optionalConstructorArg(), + (p) -> p.currentToken() == XContentParser.Token.VALUE_NULL + ? new CertificateIdentity(null) + : new CertificateIdentity(p.text()), + new ParseField("certificate_identity"), + ObjectParser.ValueType.STRING_OR_NULL + ); return parser; } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CertificateIdentity.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CertificateIdentity.java new file mode 100644 index 0000000000000..8238238440342 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CertificateIdentity.java @@ -0,0 +1,26 @@ +/* + * 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.core.Nullable; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public record CertificateIdentity(@Nullable String value) { + + public CertificateIdentity { + if (value != null) { + try { + Pattern.compile(value); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid certificate_identity format: [" + value + "]. Must be a valid regex.", e); + } + } + } +} 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 eea96bcbfcdaf..e318329bd44c1 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 @@ -21,17 +21,21 @@ public final class CreateCrossClusterApiKeyRequest extends AbstractCreateApiKeyRequest { + private final CertificateIdentity certificateIdentity; + public CreateCrossClusterApiKeyRequest( String name, CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder, @Nullable TimeValue expiration, - @Nullable Map metadata + @Nullable Map metadata, + @Nullable CertificateIdentity certificateIdentity ) { super(); this.name = Objects.requireNonNull(name); this.roleDescriptors = List.of(roleDescriptorBuilder.build()); this.expiration = expiration; this.metadata = metadata; + this.certificateIdentity = certificateIdentity; } @Override @@ -60,15 +64,21 @@ public boolean equals(Object o) { && Objects.equals(expiration, that.expiration) && Objects.equals(metadata, that.metadata) && Objects.equals(roleDescriptors, that.roleDescriptors) - && refreshPolicy == that.refreshPolicy; + && refreshPolicy == that.refreshPolicy + && Objects.equals(certificateIdentity, that.certificateIdentity); } @Override public int hashCode() { - return Objects.hash(id, name, expiration, metadata, roleDescriptors, refreshPolicy); + return Objects.hash(id, name, expiration, metadata, roleDescriptors, refreshPolicy, certificateIdentity); } public static CreateCrossClusterApiKeyRequest withNameAndAccess(String name, String access) throws IOException { - return new CreateCrossClusterApiKeyRequest(name, CrossClusterApiKeyRoleDescriptorBuilder.parse(access), null, null); + return new CreateCrossClusterApiKeyRequest(name, CrossClusterApiKeyRoleDescriptorBuilder.parse(access), null, null, null); + } + + public CertificateIdentity getCertificateIdentity() { + return certificateIdentity; } + } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponse.java index 07adb8fdc505a..58abab4412199 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponse.java @@ -126,7 +126,7 @@ public String toString() { static final ConstructingObjectParser RESPONSE_PARSER; static { - int nFieldsForParsingApiKeyInfo = 13; // this must be changed whenever ApiKey#initializeParser is changed for the number of parsers + int nFieldsForParsingApiKeyInfo = 14; // this must be changed whenever ApiKey#initializeParser is changed for the number of parsers ConstructingObjectParser keyInfoParser = new ConstructingObjectParser<>( "api_key_with_profile_uid", true, 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 ffbc5a836633c..8371bb90a47f8 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 @@ -25,7 +25,7 @@ public UpdateApiKeyRequest( @Nullable final Map metadata, @Nullable final TimeValue expiration ) { - super(roleDescriptors, metadata, expiration, id); + super(roleDescriptors, metadata, expiration, id, null); } @Override 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 index 04102e571e193..45aa700a6965b 100644 --- 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 @@ -22,9 +22,10 @@ public UpdateCrossClusterApiKeyRequest( final String id, @Nullable CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder, @Nullable final Map metadata, - @Nullable TimeValue expiration + @Nullable TimeValue expiration, + @Nullable CertificateIdentity certificateIdentity ) { - super(roleDescriptorBuilder == null ? null : List.of(roleDescriptorBuilder.build()), metadata, expiration, id); + super(roleDescriptorBuilder == null ? null : List.of(roleDescriptorBuilder.build()), metadata, expiration, id, certificateIdentity); } @Override @@ -35,9 +36,9 @@ public ApiKey.Type getType() { @Override public ActionRequestValidationException validate() { ActionRequestValidationException validationException = super.validate(); - if (roleDescriptors == null && metadata == null) { + if (roleDescriptors == null && metadata == null && certificateIdentity == null) { validationException = addValidationError( - "must update either [access] or [metadata] for cross-cluster API keys", + "must update [access], [metadata], or [certificate_identity] for cross-cluster API keys", validationException ); } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKeyTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKeyTests.java index 1bad9bdfbfc77..8c51ef9011b1d 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKeyTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKeyTests.java @@ -196,7 +196,8 @@ public static ApiKey randomApiKeyInstance() { realmType, metadata, roleDescriptors, - limitedByRoleDescriptors + limitedByRoleDescriptors, + null ); } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponseTests.java index b1b39c82cf6c1..f64c89e4a1008 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/GetApiKeyResponseTests.java @@ -64,7 +64,8 @@ public void testToXContent() throws IOException { null, null, null, - List.of() // empty limited-by role descriptor to simulate derived keys + List.of(), // empty limited-by role descriptor to simulate derived keys + null ); ApiKey apiKeyInfo2 = createApiKeyInfo( "name2", @@ -79,7 +80,8 @@ public void testToXContent() throws IOException { "realm-type-y", Map.of(), List.of(), - limitedByRoleDescriptors + limitedByRoleDescriptors, + null ); ApiKey apiKeyInfo3 = createApiKeyInfo( null, @@ -94,7 +96,8 @@ public void testToXContent() throws IOException { "realm-type-z", Map.of("foo", "bar"), roleDescriptors, - limitedByRoleDescriptors + limitedByRoleDescriptors, + null ); final List crossClusterAccessRoleDescriptors = List.of( new RoleDescriptor( @@ -119,7 +122,8 @@ public void testToXContent() throws IOException { "realm-type-z", Map.of("foo", "bar"), crossClusterAccessRoleDescriptors, - null + null, + "CN=test,O=TestOrg" ); String profileUid2 = "profileUid2"; String profileUid4 = "profileUid4"; @@ -323,6 +327,7 @@ public void testToXContent() throws IOException { } ] }, + "certificate_identity": "CN=test,O=TestOrg", "profile_uid": "profileUid4" } ] @@ -346,6 +351,7 @@ public void testMismatchApiKeyInfoAndProfileData() { null, null, null, + null, null ) ); @@ -369,7 +375,8 @@ private ApiKey createApiKeyInfo( String realmType, Map metadata, List roleDescriptors, - List limitedByRoleDescriptors + List limitedByRoleDescriptors, + String certificate_identity ) { return new ApiKey( name, @@ -384,7 +391,8 @@ private ApiKey createApiKeyInfo( realmType, metadata, roleDescriptors, - limitedByRoleDescriptors + limitedByRoleDescriptors, + certificate_identity ); } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java index 8e035da6a6d1a..03554e62e0b73 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java @@ -34,6 +34,7 @@ public void testMismatchApiKeyInfoAndProfileData() { null, null, null, + null, null ) ); @@ -68,6 +69,7 @@ public void testMismatchApiKeyInfoAndSortValues() { null, null, null, + null, null ) ); 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 index f7a0d1a6d35bf..b3b959550ed83 100644 --- 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 @@ -18,16 +18,25 @@ public class UpdateCrossClusterApiKeyRequestTests extends ESTestCase { public void testNotEmptyUpdateValidation() { - final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, null, null); + final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, null, 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")); + assertThat( + ve.validationErrors(), + contains("must update [access], [metadata], or [certificate_identity] 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), null); + final var request = new UpdateCrossClusterApiKeyRequest( + randomAlphaOfLength(10), + null, + Map.of(reservedKey, metadataValue), + null, + null + ); 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/ManageOwnApiKeyClusterPrivilegeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java index ae59cabfef73d..f6ad22c98dfb8 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 @@ -325,7 +325,8 @@ public void testCheckUpdateCrossClusterApiKeyRequestDenied() { randomAlphaOfLengthBetween(4, 7), null, Map.of(), - ApiKeyTests.randomFutureExpirationTime() + ApiKeyTests.randomFutureExpirationTime(), + null ); assertFalse(clusterPermission.check(UpdateCrossClusterApiKeyAction.NAME, request, AuthenticationTestHelper.builder().build())); } 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 5874dc6196013..5607ae951898b 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 @@ -1695,7 +1695,10 @@ public void testUpdateFailureCases() throws IOException { 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")); + assertThat( + e2.getMessage(), + containsString("must update [access], [metadata], or [certificate_identity] for cross-cluster API keys") + ); // Access cannot be empty updateRequest.setJsonEntity("{\"access\":{}}"); @@ -2112,6 +2115,299 @@ public void testWorkflowsRestrictionValidation() throws IOException { } } + public void testUpdateCrossClusterApiKeyToAddCertificateIdentity() throws IOException { + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(""" + { + "name": "cross-cluster-key-update-test", + "access": { + "search": [ + { + "names": [ "random" ] + } + ] + } + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRequest)).evaluate("id"); + final ObjectPath initialFetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(initialFetchResponse.evaluate("api_keys.0.certificate_identity"), nullValue()); + + final String certificateIdentity = "CN=updated-host,OU=engineering,DC=example,DC=com"; + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest.setJsonEntity(Strings.format(""" + { + "certificate_identity": "%s" + }""", certificateIdentity)); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse = assertOKAndCreateObjectPath(client().performRequest(updateRequest)); + assertThat(updateResponse.evaluate("updated"), is(true)); + + final ObjectPath fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(fetchResponse.evaluate("api_keys.0.certificate_identity"), equalTo(certificateIdentity)); + } + + public void testUpdateCrossClusterApiKeyToChangeCertificateIdentity() throws IOException { + final String originalCertIdentity = "CN=original-host,OU=engineering,DC=example,DC=com"; + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(Strings.format(""" + { + "name": "cross-cluster-key-change-cert", + "access": { + "search": [ + { + "names": [ "metrics" ] + } + ] + }, + "certificate_identity": "%s" + }""", originalCertIdentity)); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRequest)).evaluate("id"); + final ObjectPath initialFetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(initialFetchResponse.evaluate("api_keys.0.certificate_identity"), equalTo(originalCertIdentity)); + + final String newCertificateIdentity = "CN=new-host,OU=security,DC=example,DC=com"; + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest.setJsonEntity(Strings.format(""" + { + "certificate_identity": "%s" + }""", newCertificateIdentity)); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse = assertOKAndCreateObjectPath(client().performRequest(updateRequest)); + assertThat(updateResponse.evaluate("updated"), is(true)); + + final ObjectPath fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(fetchResponse.evaluate("api_keys.0.certificate_identity"), equalTo(newCertificateIdentity)); + } + + public void testUpdateCrossClusterApiKeyCertificateIdentityNoop() throws IOException { + final String certificateIdentity = "CN=test-host,OU=engineering,DC=example,DC=com"; + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(Strings.format(""" + { + "name": "cross-cluster-key-noop-test", + "access": { + "search": [ + { + "names": [ "something" ] + } + ] + }, + "certificate_identity": "%s" + }""", certificateIdentity)); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRequest)).evaluate("id"); + + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest.setJsonEntity(Strings.format(""" + { + "certificate_identity": "%s" + }""", certificateIdentity)); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse = assertOKAndCreateObjectPath(client().performRequest(updateRequest)); + assertThat(updateResponse.evaluate("updated"), is(false)); + } + + public void testUpdateCrossClusterApiKeyToRemoveCertificateIdentity() throws IOException { + // Create API key with certificate identity + final String initialCertIdentity = "CN=initial-host,OU=engineering,DC=example,DC=com"; + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(Strings.format(""" + { + "name": "cross-cluster-key-remove-cert", + "access": { + "search": [ + { + "names": [ "logs" ] + } + ] + }, + "certificate_identity": "%s" + }""", initialCertIdentity)); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRequest)).evaluate("id"); + + // Verify initial certificate identity + ObjectPath fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(fetchResponse.evaluate("api_keys.0.certificate_identity"), equalTo(initialCertIdentity)); + + // Update to remove certificate identity (explicit null) + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest.setJsonEntity(""" + { + "certificate_identity": null + }"""); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse = assertOKAndCreateObjectPath(client().performRequest(updateRequest)); + assertThat(updateResponse.evaluate("updated"), is(true)); + + // Verify certificate identity was removed + fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(fetchResponse.evaluate("api_keys.0.certificate_identity"), nullValue()); + } + + @SuppressWarnings("unchecked") + public void testUpdateMultipleApiKeysWithCertificateIdentityShouldFail() throws IOException { + // Create two API keys without certificate identity + final Request createRequest1 = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest1.setJsonEntity(""" + { + "name": "key-1", + "access": { + "search": [ + { + "names": [ "index1" ] + } + ] + } + }"""); + setUserForRequest(createRequest1, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId1 = assertOKAndCreateObjectPath(client().performRequest(createRequest1)).evaluate("id"); + + final Request createRequest2 = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest2.setJsonEntity(""" + { + "name": "key-2", + "access": { + "search": [ + { + "names": [ "index2" ] + } + ] + } + }"""); + setUserForRequest(createRequest2, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId2 = assertOKAndCreateObjectPath(client().performRequest(createRequest2)).evaluate("id"); + + // Attempt to update both with a certificate identity - should fail + final Request bulkUpdateRequest = new Request("POST", "/_security/api_key/_bulk_update"); + bulkUpdateRequest.setJsonEntity(Strings.format(""" + { + "ids": ["%s", "%s"], + "certificate_identity": "CN=bulk-update,DC=example,DC=com" + }""", apiKeyId1, apiKeyId2)); + setUserForRequest(bulkUpdateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + // This should succeed (200 OK) but contain errors + final Response bulkUpdateResponse = client().performRequest(bulkUpdateRequest); + assertOK(bulkUpdateResponse); + + final Map responseMap = responseAsMap(bulkUpdateResponse); + final Map errors = (Map) responseMap.get("errors"); + assertThat(errors.get("count"), equalTo(2)); + + final Map> errorDetails = (Map>) errors.get("details"); + assertThat( + (String) errorDetails.get(apiKeyId1).get("reason"), + containsString("cannot update API key of type [cross_cluster] while expected type is [rest]") + ); + assertThat( + (String) errorDetails.get(apiKeyId2).get("reason"), + containsString("cannot update API key of type [cross_cluster] while expected type is [rest]") + ); + } + + public void testUpdateCrossClusterApiKeyPreservesCertificateIdentityWhenNotSpecified() throws IOException { + final String certIdentity = "CN=preserve-test,OU=engineering,DC=example,DC=com"; + + // Create API key with certificate identity + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(Strings.format(""" + { + "name": "preserve-cert-key", + "access": { + "search": [ + { + "names": [ "data" ] + } + ] + }, + "certificate_identity": "%s" + }""", certIdentity)); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRequest)).evaluate("id"); + + // Update only the access permissions (not certificate_identity) + final Request updateRequest = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId); + updateRequest.setJsonEntity(""" + { + "access": { + "search": [ + { + "names": [ "updated-data" ] + } + ] + } + }"""); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final ObjectPath updateResponse = assertOKAndCreateObjectPath(client().performRequest(updateRequest)); + assertThat(updateResponse.evaluate("updated"), is(true)); + + // Verify certificate identity is preserved + final ObjectPath fetchResponse = fetchCrossClusterApiKeyById(apiKeyId); + assertThat(fetchResponse.evaluate("api_keys.0.certificate_identity"), equalTo(certIdentity)); + } + + public void testCreateCrossClusterApiKeyWithInvalidCertificateIdentity() throws IOException { + // Test with invalid regex certificate identity + { + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(""" + { + "name": "invalid-cert-key", + "access": { + "search": [ + { + "names": [ "test" ] + } + ] + }, + "certificate_identity": "[" + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(createRequest)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e.getMessage(), containsString("Invalid certificate_identity format")); + } + } + + public void testCannotSetCertificateIdentityOnRegularApiKey() throws IOException { + // Test that creating a regular API key with certificate_identity fails + final Request createRequest = new Request("POST", "_security/api_key"); + createRequest.setJsonEntity(""" + { + "name": "regular-key-with-cert", + "certificate_identity": "CN=test-host,OU=engineering,DC=example,DC=com" + }"""); + setUserForRequest(createRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + ResponseException e1 = expectThrows(ResponseException.class, () -> client().performRequest(createRequest)); + assertThat(e1.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e1.getMessage(), containsString("unknown field [certificate_identity")); + + // Test that updating a regular API key with certificate_identity fails + final Request createRegularKeyRequest = new Request("POST", "_security/api_key"); + createRegularKeyRequest.setJsonEntity(""" + { + "name": "regular-key" + }"""); + setUserForRequest(createRegularKeyRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + final String apiKeyId = assertOKAndCreateObjectPath(client().performRequest(createRegularKeyRequest)).evaluate("id"); + + final Request updateRequest = new Request("PUT", "_security/api_key/" + apiKeyId); + updateRequest.setJsonEntity(""" + { + "certificate_identity": "CN=test-host,OU=engineering,DC=example,DC=com" + }"""); + setUserForRequest(updateRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); + + ResponseException e2 = expectThrows(ResponseException.class, () -> client().performRequest(updateRequest)); + assertThat(e2.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + assertThat(e2.getMessage(), containsString("unknown field [certificate_identity")); + } + private Response performRequestWithManageOwnApiKeyUser(Request request) throws IOException { request.setOptions( RequestOptions.DEFAULT.toBuilder() diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 70dcfbaa315cf..d30e7d41d063c 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -56,8 +56,12 @@ import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction; 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.CertificateIdentity; 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; @@ -70,6 +74,8 @@ 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.action.apikey.UpdateCrossClusterApiKeyAction; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.core.security.action.role.PutRoleAction; import org.elasticsearch.xpack.core.security.action.role.PutRoleRequest; import org.elasticsearch.xpack.core.security.action.role.PutRoleResponse; @@ -2763,6 +2769,192 @@ public void testUpdateApiKeysClearsApiKeyDocCache() throws Exception { assertEquals(serviceForDoc2AuthCacheCount, serviceForDoc2.getApiKeyAuthCache().count()); } + public void testCreateCrossClusterApiKeyWithCertificateIdentity() throws Exception { + final String certificateIdentity = "CN=remote-cluster-cert"; + final String keyName = randomAlphaOfLengthBetween(3, 8); + + final CrossClusterApiKeyRoleDescriptorBuilder roleBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(""" + { + "search": [ {"names": ["logs"]} ] + }"""); + + final var request = new CreateCrossClusterApiKeyRequest( + keyName, + roleBuilder, + null, + null, + new CertificateIdentity(certificateIdentity) + ); + request.setRefreshPolicy(randomFrom(IMMEDIATE, WAIT_UNTIL)); + + final PlainActionFuture future = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + final CreateApiKeyResponse response = future.actionGet(); + + assertEquals(keyName, response.getName()); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + + final Map apiKeyDoc = getApiKeyDocument(response.getId()); + assertThat(apiKeyDoc.get("certificate_identity"), equalTo(certificateIdentity)); + assertThat(apiKeyDoc.get("type"), equalTo("cross_cluster")); + } + + public void testCreateCrossClusterApiKeyWithoutCertificateIdentity() throws Exception { + final String keyName = randomAlphaOfLengthBetween(3, 8); + + final var request = CreateCrossClusterApiKeyRequest.withNameAndAccess(keyName, """ + { + "search": [ {"names": ["logs"]} ] + }"""); + request.setRefreshPolicy(randomFrom(IMMEDIATE, WAIT_UNTIL)); + + final PlainActionFuture future = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, request, future); + final CreateApiKeyResponse response = future.actionGet(); + + assertEquals(keyName, response.getName()); + assertNotNull(response.getId()); + assertNotNull(response.getKey()); + + final Map apiKeyDoc = getApiKeyDocument(response.getId()); + assertThat(apiKeyDoc.containsKey("certificate_identity"), is(false)); + assertThat(apiKeyDoc.get("type"), equalTo("cross_cluster")); + } + + public void testUpdateCrossClusterApiKeyWithCertificateIdentity() throws Exception { + // Create a cross-cluster API key first + final String keyName = randomAlphaOfLengthBetween(3, 8); + final CrossClusterApiKeyRoleDescriptorBuilder roleBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(""" + { + "search": [ {"names": ["logs"]} ] + }"""); + + final var createRequest = new CreateCrossClusterApiKeyRequest( + keyName, + roleBuilder, + null, + null, + new CertificateIdentity("CN=original-cert") + ); + createRequest.setRefreshPolicy(IMMEDIATE); + + final PlainActionFuture createFuture = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, createRequest, createFuture); + final CreateApiKeyResponse createdApiKey = createFuture.actionGet(); + final var apiKeyId = createdApiKey.getId(); + + // Verify original certificate identity is set + Map apiKeyDoc = getApiKeyDocument(apiKeyId); + assertThat(apiKeyDoc.get("certificate_identity"), equalTo("CN=original-cert")); + assertThat(apiKeyDoc.get("type"), equalTo("cross_cluster")); + + // Now test updating the certificate identity using UpdateCrossClusterApiKeyRequest + final var newCertIdentity = "CN=updated-cert"; + final UpdateCrossClusterApiKeyRequest updateRequest = new UpdateCrossClusterApiKeyRequest( + apiKeyId, + null, + null, + null, + new CertificateIdentity(newCertIdentity) + ); + + final PlainActionFuture updateFuture = new PlainActionFuture<>(); + client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateRequest, updateFuture); + final UpdateApiKeyResponse response = updateFuture.actionGet(); + + assertNotNull(response); + assertTrue(response.isUpdated()); + + apiKeyDoc = getApiKeyDocument(apiKeyId); + assertThat(apiKeyDoc.get("certificate_identity"), equalTo(newCertIdentity)); + assertThat(apiKeyDoc.get("type"), equalTo("cross_cluster")); + } + + public void testUpdateCrossClusterApiKeyClearCertificateIdentity() throws Exception { + // Create a cross-cluster API key with certificate identity + final String keyName = randomAlphaOfLengthBetween(3, 8); + final CrossClusterApiKeyRoleDescriptorBuilder roleBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(""" + { + "search": [ {"names": ["logs"]} ] + }"""); + + final var createRequest = new CreateCrossClusterApiKeyRequest( + keyName, + roleBuilder, + null, + null, + new CertificateIdentity("CN=to-be-cleared") + ); + createRequest.setRefreshPolicy(IMMEDIATE); + + final PlainActionFuture createFuture = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, createRequest, createFuture); + final CreateApiKeyResponse createdApiKey = createFuture.actionGet(); + final var apiKeyId = createdApiKey.getId(); + + final UpdateCrossClusterApiKeyRequest updateRequest = new UpdateCrossClusterApiKeyRequest( + apiKeyId, + null, + null, + null, + new CertificateIdentity(null) + ); + + final PlainActionFuture updateFuture = new PlainActionFuture<>(); + client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateRequest, updateFuture); + final UpdateApiKeyResponse response = updateFuture.actionGet(); + + assertNotNull(response); + assertTrue(response.isUpdated()); + + final Map apiKeyDoc = getApiKeyDocument(apiKeyId); + assertThat(apiKeyDoc.containsKey("certificate_identity"), is(false)); + } + + public void testUpdateCrossClusterApiKeyPreserveCertificateIdentity() throws Exception { + // Create a cross-cluster API key with certificate identity + final String keyName = randomAlphaOfLengthBetween(3, 8); + final CrossClusterApiKeyRoleDescriptorBuilder roleBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(""" + { + "search": [ {"names": ["logs"]} ] + }"""); + + final var createRequest = new CreateCrossClusterApiKeyRequest( + keyName, + roleBuilder, + null, + null, + new CertificateIdentity("CN=preserve-me") + ); + createRequest.setRefreshPolicy(IMMEDIATE); + + final PlainActionFuture createFuture = new PlainActionFuture<>(); + client().execute(CreateCrossClusterApiKeyAction.INSTANCE, createRequest, createFuture); + final CreateApiKeyResponse createdApiKey = createFuture.actionGet(); + final var apiKeyId = createdApiKey.getId(); + + // Update without specifying certificate identity (should preserve existing) + final UpdateCrossClusterApiKeyRequest updateRequest = new UpdateCrossClusterApiKeyRequest( + apiKeyId, + null, + Map.of("updated", "true"), + null, + null + ); + + final PlainActionFuture updateFuture = new PlainActionFuture<>(); + client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateRequest, updateFuture); + final UpdateApiKeyResponse response = updateFuture.actionGet(); + + assertNotNull(response); + assertTrue(response.isUpdated()); + + // Verify the certificate identity was preserved + final Map apiKeyDoc = getApiKeyDocument(apiKeyId); + assertThat(apiKeyDoc.get("certificate_identity"), equalTo("CN=preserve-me")); + } + private List randomRoleDescriptors() { int caseNo = randomIntBetween(0, 3); return switch (caseNo) { 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 bc413ff2001ab..590ea1461fde2 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 @@ -719,7 +719,13 @@ public void testUpdateCrossClusterApiKey() throws IOException { ApiKeyTests.randomFutureExpirationTime(); } - final var updateApiKeyRequest = new UpdateCrossClusterApiKeyRequest(apiKeyId, roleDescriptorBuilder, updateMetadata, expiration); + final var updateApiKeyRequest = new UpdateCrossClusterApiKeyRequest( + apiKeyId, + roleDescriptorBuilder, + updateMetadata, + expiration, + null + ); final UpdateApiKeyResponse updateApiKeyResponse = client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateApiKeyRequest) .actionGet(); 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 2cd42d41e7963..04331311ef296 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 @@ -1018,7 +1018,8 @@ Collection createComponents( clusterService, cacheInvalidatorRegistry, threadPool, - telemetryProvider.getMeterRegistry() + telemetryProvider.getMeterRegistry(), + featureService ); components.add(apiKeyService); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java index 8c93003def79f..9c7ee9c819972 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java @@ -15,11 +15,12 @@ import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer.QUERYABLE_BUILT_IN_ROLES_FEATURE; public class SecurityFeatures implements FeatureSpecification { + public static final NodeFeature CERTIFICATE_IDENTITY_FIELD_FEATURE = new NodeFeature("certificate_identity_field"); public static final NodeFeature SECURITY_STATS_ENDPOINT = new NodeFeature("security_stats_endpoint"); @Override public Set getFeatures() { - return Set.of(QUERYABLE_BUILT_IN_ROLES_FEATURE, SECURITY_STATS_ENDPOINT); + return Set.of(QUERYABLE_BUILT_IN_ROLES_FEATURE, CERTIFICATE_IDENTITY_FIELD_FEATURE, SECURITY_STATS_ENDPOINT); } } 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 index e49746fbe87a0..93ac15d14cd03 100644 --- 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 @@ -55,6 +55,9 @@ protected void doExecute(Task task, CreateCrossClusterApiKeyRequest request, Act ) ); } else { + if (request.getCertificateIdentity() != null) { + apiKeyService.ensureCertificateIdentityFeatureIsEnabled(); + } apiKeyService.createApiKey(authentication, request, Set.of(), listener); } } 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 index 2c2bc1c952d37..e73addb9b7a9e 100644 --- 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 @@ -48,13 +48,18 @@ void doExecuteUpdate( final Authentication authentication, final ActionListener listener ) { + if (request.getCertificateIdentity() != null) { + apiKeyService.ensureCertificateIdentityFeatureIsEnabled(); + } + apiKeyService.updateApiKeys( authentication, new BaseBulkUpdateApiKeyRequest( List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata(), - request.getExpiration() + request.getExpiration(), + request.getCertificateIdentity() ) { @Override public ApiKey.Type getType() { 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 4d1bd3b0c1c38..c0d00c3838597 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 @@ -35,6 +35,7 @@ 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.node.DiscoveryNode; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.Strings; @@ -62,6 +63,7 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.Tuple; +import org.elasticsearch.features.FeatureService; import org.elasticsearch.index.query.BoolQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryBuilders; @@ -89,7 +91,9 @@ 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.BulkUpdateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.CertificateIdentity; 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.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; @@ -150,6 +154,7 @@ import static org.elasticsearch.xpack.core.security.authz.RoleDescriptor.WORKFLOWS_RESTRICTION_VERSION; import static org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermissions.ROLE_REMOTE_CLUSTER_PRIVS; import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME; +import static org.elasticsearch.xpack.security.SecurityFeatures.CERTIFICATE_IDENTITY_FIELD_FEATURE; import static org.elasticsearch.xpack.security.support.SecurityIndexManager.Availability.PRIMARY_SHARDS; import static org.elasticsearch.xpack.security.support.SecurityIndexManager.Availability.SEARCH_SHARDS; import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS; @@ -217,6 +222,7 @@ public class ApiKeyService implements Closeable { private final Hasher cacheHasher; private final ThreadPool threadPool; private final ApiKeyDocCache apiKeyDocCache; + private final FeatureService featureService; private static final int API_KEY_SECRET_NUM_BYTES = 16; // The API key secret is a Base64 encoded string of 128 random bits. @@ -239,7 +245,8 @@ public ApiKeyService( ClusterService clusterService, CacheInvalidatorRegistry cacheInvalidatorRegistry, ThreadPool threadPool, - MeterRegistry meterRegistry + MeterRegistry meterRegistry, + FeatureService featureService ) { this.clock = clock; this.client = client; @@ -251,6 +258,8 @@ public ApiKeyService( this.inactiveApiKeysRemover = new InactiveApiKeysRemover(settings, client, clusterService); this.threadPool = threadPool; this.cacheHasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings)); + this.featureService = featureService; + final TimeValue ttl = CACHE_TTL_SETTING.get(settings); final int maximumWeight = CACHE_MAX_KEYS_SETTING.get(settings); if (ttl.getNanos() > 0) { @@ -553,6 +562,14 @@ private void createApiKeyAndIndexIt( : "Invalid API key (name=[" + request.getName() + "], type=[" + request.getType() + "], length=[" + apiKey.length() + "])"; computeHashForApiKey(apiKey, listener.delegateFailure((l, apiKeyHashChars) -> { + final String certificateIdentity; + try { + certificateIdentity = getCertificateIdentityFromCreateRequest(request); + } catch (ElasticsearchException e) { + listener.onFailure(e); + return; + } + try ( XContentBuilder builder = newDocument( apiKeyHashChars, @@ -564,7 +581,8 @@ private void createApiKeyAndIndexIt( request.getRoleDescriptors(), request.getType(), ApiKey.CURRENT_API_KEY_VERSION, - request.getMetadata() + request.getMetadata(), + certificateIdentity ) ) { final BulkRequestBuilder bulkRequestBuilder = client.prepareBulk(); @@ -614,6 +632,27 @@ private void createApiKeyAndIndexIt( })); } + private String getCertificateIdentityFromCreateRequest(final AbstractCreateApiKeyRequest request) { + String certificateIdentityString = null; + if (request instanceof CreateCrossClusterApiKeyRequest createCrossClusterApiKeyRequest) { + CertificateIdentity certIdentityObject = createCrossClusterApiKeyRequest.getCertificateIdentity(); + if (certIdentityObject != null) { + certificateIdentityString = certIdentityObject.value(); + } + } + return certificateIdentityString; + } + + public void ensureCertificateIdentityFeatureIsEnabled() { + ClusterState clusterState = clusterService.state(); + if (featureService.clusterHasFeature(clusterState, CERTIFICATE_IDENTITY_FIELD_FEATURE) == false) { + throw new ElasticsearchException( + "API key operation failed. The cluster is in a mixed-version state and does not yet " + + "support the [certificate_identity] field. Please retry after the upgrade is complete." + ); + } + } + public void updateApiKeys( final Authentication authentication, final BaseBulkUpdateApiKeyRequest request, @@ -896,7 +935,8 @@ static XContentBuilder newDocument( List keyRoleDescriptors, ApiKey.Type type, ApiKey.Version version, - @Nullable Map metadata + @Nullable Map metadata, + @Nullable String certificateIdentity ) throws IOException { final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject() @@ -911,6 +951,10 @@ static XContentBuilder newDocument( addLimitedByRoleDescriptors(builder, userRoleDescriptors); builder.field("name", name).field("version", version.version()).field("metadata_flattened", metadata); + + if (certificateIdentity != null) { + builder.field("certificate_identity", certificateIdentity); + } addCreator(builder, authentication); return builder.endObject(); @@ -990,6 +1034,27 @@ static XContentBuilder maybeBuildUpdatedDocument( ); } + CertificateIdentity certIdentityRequest = request.getCertificateIdentity(); + + if (certIdentityRequest == null) { + // certificate_identity was omitted from request; preserve existing value + if (currentApiKeyDoc.certificateIdentity != null) { + logger.trace(() -> format("Preserving existing certificate identity for API key [%s]", apiKeyId)); + builder.field("certificate_identity", currentApiKeyDoc.certificateIdentity); + } + } else { + String newValue = certIdentityRequest.value(); + if (newValue == null) { + // Explicit null provided for certificate_identity in request; clear the certificate_identity + logger.trace(() -> format("Clearing certificate identity for API key [%s]", apiKeyId)); + // Don't add certificate_identity field to document (effectively removes it) + } else { + // A new value was provided for certificate_identity; update to the new value. + logger.trace(() -> format("Updating certificate identity for API key [%s]", apiKeyId)); + builder.field("certificate_identity", newValue); + } + } + addCreator(builder, authentication); return builder.endObject(); @@ -1003,6 +1068,15 @@ private static boolean isNoop( final BaseUpdateApiKeyRequest request, final Set userRoleDescriptors ) throws IOException { + + final CertificateIdentity newCertificateIdentity = request.getCertificateIdentity(); + if (newCertificateIdentity != null) { + String newCertificateIdentityStringValue = newCertificateIdentity.value(); + if (Objects.equals(newCertificateIdentityStringValue, apiKeyDoc.certificateIdentity) == false) { + return false; + } + } + if (apiKeyDoc.version != targetDocVersion.version()) { return false; } @@ -1083,6 +1157,7 @@ private static boolean isNoop( // `LEGACY_SUPERUSER_ROLE_DESCRIPTOR` to `ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR`, when we update a 7.x API key. false ); + return (userRoleDescriptors.size() == currentLimitedByRoleDescriptors.size() && userRoleDescriptors.containsAll(currentLimitedByRoleDescriptors)); } @@ -2324,7 +2399,8 @@ private ApiKey convertSearchHitToApiKeyInfo(SearchHit hit, boolean withLimitedBy (String) apiKeyDoc.creator.get("realm_type"), metadata, roleDescriptors, - limitedByRoleDescriptors + limitedByRoleDescriptors, + apiKeyDoc.certificateIdentity ); } @@ -2517,6 +2593,7 @@ public static final class ApiKeyDoc { ObjectParserHelper.declareRawObject(builder, constructorArg(), new ParseField("limited_by_role_descriptors")); builder.declareObject(constructorArg(), (p, c) -> p.map(), new ParseField("creator")); ObjectParserHelper.declareRawObjectOrNull(builder, optionalConstructorArg(), new ParseField("metadata_flattened")); + builder.declareStringOrNull(optionalConstructorArg(), new ParseField("certificate_identity")); PARSER = builder.build(); } @@ -2535,6 +2612,8 @@ public static final class ApiKeyDoc { final Map creator; @Nullable final BytesReference metadataFlattened; + @Nullable + final String certificateIdentity; public ApiKeyDoc( String docType, @@ -2549,7 +2628,8 @@ public ApiKeyDoc( BytesReference roleDescriptorsBytes, BytesReference limitedByRoleDescriptorsBytes, Map creator, - @Nullable BytesReference metadataFlattened + @Nullable BytesReference metadataFlattened, + @Nullable String certificateIdentity ) { this.docType = docType; if (type == null) { @@ -2569,6 +2649,7 @@ public ApiKeyDoc( this.limitedByRoleDescriptorsBytes = limitedByRoleDescriptorsBytes; this.creator = creator; this.metadataFlattened = NULL_BYTES.equals(metadataFlattened) ? null : metadataFlattened; + this.certificateIdentity = certificateIdentity; } public CachedApiKeyDoc toCachedApiKeyDoc() { @@ -2590,7 +2671,8 @@ public CachedApiKeyDoc toCachedApiKeyDoc() { creator, roleDescriptorsHash, limitedByRoleDescriptorsHash, - metadataFlattened + metadataFlattened, + certificateIdentity ); } @@ -2618,6 +2700,8 @@ public static final class CachedApiKeyDoc { final String limitedByRoleDescriptorsHash; @Nullable final BytesReference metadataFlattened; + @Nullable + final String certificateIdentity; public CachedApiKeyDoc( ApiKey.Type type, @@ -2631,7 +2715,8 @@ public CachedApiKeyDoc( Map creator, String roleDescriptorsHash, String limitedByRoleDescriptorsHash, - @Nullable BytesReference metadataFlattened + @Nullable BytesReference metadataFlattened, + @Nullable String certificateIdentity ) { this.type = type; this.creationTime = creationTime; @@ -2645,6 +2730,7 @@ public CachedApiKeyDoc( this.roleDescriptorsHash = roleDescriptorsHash; this.limitedByRoleDescriptorsHash = limitedByRoleDescriptorsHash; this.metadataFlattened = metadataFlattened; + this.certificateIdentity = certificateIdentity; } public ApiKeyDoc toApiKeyDoc(BytesReference roleDescriptorsBytes, BytesReference limitedByRoleDescriptorsBytes) { @@ -2661,7 +2747,8 @@ public ApiKeyDoc toApiKeyDoc(BytesReference roleDescriptorsBytes, BytesReference roleDescriptorsBytes, limitedByRoleDescriptorsBytes, creator, - metadataFlattened + metadataFlattened, + certificateIdentity ); } } 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 index 5de32cbf1a104..e5ac0c57ac391 100644 --- 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 @@ -15,7 +15,9 @@ import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.action.RestToXContentListener; import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ObjectParser; import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xpack.core.security.action.apikey.CertificateIdentity; 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; @@ -43,7 +45,8 @@ public final class RestCreateCrossClusterApiKeyAction extends ApiKeyBaseRestHand (String) args[0], (CrossClusterApiKeyRoleDescriptorBuilder) args[1], TimeValue.parseTimeValue((String) args[2], null, "expiration"), - (Map) args[3] + (Map) args[3], + (CertificateIdentity) args[4] ) ); @@ -52,6 +55,12 @@ public final class RestCreateCrossClusterApiKeyAction extends ApiKeyBaseRestHand PARSER.declareObject(constructorArg(), CrossClusterApiKeyRoleDescriptorBuilder.PARSER, new ParseField("access")); PARSER.declareString(optionalConstructorArg(), new ParseField("expiration")); PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); + PARSER.declareField( + optionalConstructorArg(), + (p) -> new CertificateIdentity(p.text()), + new ParseField("certificate_identity"), + ObjectParser.ValueType.STRING + ); } /** 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 index e9244eaea0ec5..761c102fcf509 100644 --- 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 @@ -15,7 +15,10 @@ import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.action.RestToXContentListener; import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ObjectParser; import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xpack.core.security.action.apikey.CertificateIdentity; 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; @@ -36,7 +39,8 @@ public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHand a -> new Payload( (CrossClusterApiKeyRoleDescriptorBuilder) a[0], (Map) a[1], - TimeValue.parseTimeValue((String) a[2], null, "expiration") + TimeValue.parseTimeValue((String) a[2], null, "expiration"), + (CertificateIdentity) a[3] ) ); @@ -44,6 +48,12 @@ public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHand PARSER.declareObject(optionalConstructorArg(), CrossClusterApiKeyRoleDescriptorBuilder.PARSER, new ParseField("access")); PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); PARSER.declareString(optionalConstructorArg(), new ParseField("expiration")); + PARSER.declareField( + optionalConstructorArg(), + (p) -> p.currentToken() == XContentParser.Token.VALUE_NULL ? new CertificateIdentity(null) : new CertificateIdentity(p.text()), + new ParseField("certificate_identity"), + ObjectParser.ValueType.STRING_OR_NULL + ); } public RestUpdateCrossClusterApiKeyAction(final Settings settings, final XPackLicenseState licenseState) { @@ -67,7 +77,13 @@ protected RestChannelConsumer innerPrepareRequest(final RestRequest request, fin return channel -> client.execute( UpdateCrossClusterApiKeyAction.INSTANCE, - new UpdateCrossClusterApiKeyRequest(apiKeyId, payload.builder, payload.metadata, payload.expiration), + new UpdateCrossClusterApiKeyRequest( + apiKeyId, + payload.builder, + payload.metadata, + payload.expiration, + payload.certificateIdentity + ), new RestToXContentListener<>(channel) ); } @@ -81,5 +97,10 @@ protected Exception innerCheckFeatureAvailable(RestRequest request) { } } - record Payload(CrossClusterApiKeyRoleDescriptorBuilder builder, Map metadata, TimeValue expiration) {} + record Payload( + CrossClusterApiKeyRoleDescriptorBuilder builder, + Map metadata, + TimeValue expiration, + CertificateIdentity certificateIdentity + ) {} } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java index 24cf355818e3e..5a0b064162e33 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java @@ -509,6 +509,12 @@ private XContentBuilder getMainIndexMappings(SecurityMainIndexMappingVersion map builder.field("type", "boolean"); builder.endObject(); + if (mappingVersion.onOrAfter(SecurityMainIndexMappingVersion.ADD_CERTIFICATE_IDENTITY_FIELD)) { + builder.startObject("certificate_identity"); + builder.field("type", "keyword"); + builder.endObject(); + } + builder.startObject("role_descriptors"); builder.field("type", "object"); builder.field("enabled", false); @@ -680,6 +686,7 @@ private XContentBuilder getMainIndexMappings(SecurityMainIndexMappingVersion map builder.endObject(); } builder.endObject(); + } builder.endObject(); } @@ -1097,6 +1104,11 @@ public enum SecurityMainIndexMappingVersion implements VersionId, Map> newApiKeyDocument keyRoles, type, ApiKey.CURRENT_API_KEY_VERSION, - metadataMap + metadataMap, + null ); Map keyMap = XContentHelper.convertToMap(BytesReference.bytes(docSource), true, XContentType.JSON).v2(); if (invalidated) { @@ -1348,7 +1352,7 @@ public void testParseRoleDescriptorsMap() throws Exception { ActionListener> listener = (ActionListener>) arg2; listener.onResponse(Collections.emptyList()); return null; - }).when(privilegesStore).getPrivileges(any(Collection.class), any(Collection.class), anyActionListener()); + }).when(privilegesStore).getPrivileges(any(Collection.class), any(Collection.class), any(ActionListener.class)); ApiKeyService service = createApiKeyService(Settings.EMPTY); assertThat(service.parseRoleDescriptors(apiKeyId, null, randomApiKeyRoleType()), nullValue()); @@ -2756,7 +2760,8 @@ public void testMaybeBuildUpdatedDocument() throws IOException { oldKeyRoles, type, oldVersion, - oldMetadata + oldMetadata, + null ) ), XContentType.JSON @@ -3162,7 +3167,8 @@ public void testValidateOwnerUserRoleDescriptorsWithWorkflowsRestriction() { clusterService, cacheInvalidatorRegistry, threadPool, - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ); final Set userRoleDescriptorsWithWorkflowsRestriction = randomSet( @@ -3202,6 +3208,123 @@ public void testValidateOwnerUserRoleDescriptorsWithWorkflowsRestriction() { assertThat(e2.getMessage(), containsString("owner user role descriptors must not include restriction")); } + public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Exception { + final String apiKeyId = randomAlphaOfLength(12); + final Clock mockClock = mock(Clock.class); + when(mockClock.instant()).thenReturn(Instant.now()); + + // Scenario 1: Update with a new value + { + final String originalCertIdentity = "CN=old-host,OU=engineering,DC=example,DC=com"; + final String newCertIdentity = "CN=new-host,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(originalCertIdentity); + final BaseBulkUpdateApiKeyRequest updateRequest = createUpdateRequestWithCertificateIdentity( + apiKeyId, + new CertificateIdentity(newCertIdentity), + null + ); + final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument( + apiKeyId, + apiKeyDoc, + ApiKey.CURRENT_API_KEY_VERSION, + createTestAuthentication(), + updateRequest, + Set.of(), + mockClock + ); + assertThat(builder, notNullValue()); + final Map updatedDoc = extractDocumentContent(builder); + assertThat(updatedDoc.get("certificate_identity"), equalTo(newCertIdentity)); + } + + // Scenario 2: No-op update (same value) + { + final String certIdentity = "CN=host,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certIdentity); + final BaseBulkUpdateApiKeyRequest updateRequest = createUpdateRequestWithCertificateIdentity( + apiKeyId, + new CertificateIdentity(certIdentity), + null + ); + final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument( + apiKeyId, + apiKeyDoc, + ApiKey.CURRENT_API_KEY_VERSION, + createTestAuthentication(), + updateRequest, + Set.of(), + mockClock + ); + assertThat(builder, nullValue()); + } + + // Scenario 3: Explicitly clear an existing value + { + final String existingCertIdentity = "CN=existing-host,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(existingCertIdentity); + final BaseBulkUpdateApiKeyRequest updateRequest = createUpdateRequestWithCertificateIdentity( + apiKeyId, + new CertificateIdentity(null), + null + ); + final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument( + apiKeyId, + apiKeyDoc, + ApiKey.CURRENT_API_KEY_VERSION, + createTestAuthentication(), + updateRequest, + Set.of(), + mockClock + ); + assertThat(builder, notNullValue()); + final Map updatedDoc = extractDocumentContent(builder); + assertThat(updatedDoc.containsKey("certificate_identity"), is(false)); + } + + // Scenario 4: Omit the field, should preserve existing value + { + final String existingCertIdentity = "CN=existing,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(existingCertIdentity); + final BaseBulkUpdateApiKeyRequest updateRequest = createUpdateRequestWithCertificateIdentity( + apiKeyId, + null, + Map.of("updated", "value") + ); + final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument( + apiKeyId, + apiKeyDoc, + ApiKey.CURRENT_API_KEY_VERSION, + createTestAuthentication(), + updateRequest, + Set.of(), + mockClock + ); + assertThat(builder, notNullValue()); + final Map updatedDoc = extractDocumentContent(builder); + assertThat(updatedDoc.get("certificate_identity"), equalTo(existingCertIdentity)); + } + + // Scenario 5: Explicitly clear a value that doesn't exist + { + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(null); + final BaseBulkUpdateApiKeyRequest updateRequest = createUpdateRequestWithCertificateIdentity( + apiKeyId, + new CertificateIdentity(null), + null + ); + final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument( + apiKeyId, + apiKeyDoc, + ApiKey.CURRENT_API_KEY_VERSION, + createTestAuthentication(), + updateRequest, + Set.of(), + mockClock + ); + assertThat(builder, nullValue()); + } + } + private static RoleDescriptor randomRoleDescriptorWithRemotePrivileges() { return new RoleDescriptor( randomAlphaOfLengthBetween(3, 90), @@ -3250,7 +3373,8 @@ public static Authentication createApiKeyAuthentication( keyRoles, ApiKey.Type.REST, ApiKey.CURRENT_API_KEY_VERSION, - randomBoolean() ? null : Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)) + randomBoolean() ? null : Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)), + null ); final ApiKeyDoc apiKeyDoc = ApiKeyDoc.fromXContent( XContentHelper.createParser( @@ -3310,6 +3434,37 @@ public static Authentication createApiKeyAuthentication(ApiKeyService apiKeyServ } } + private ApiKeyService createApiKeyService(Settings baseSettings, FeatureService customFeatureService) { + final Settings settings = Settings.builder() + .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) + .put(baseSettings) + .build(); + final ClusterSettings clusterSettings = new ClusterSettings( + settings, + Sets.union( + ClusterSettings.BUILT_IN_CLUSTER_SETTINGS, + Set.of(ApiKeyService.DELETE_RETENTION_PERIOD, ApiKeyService.DELETE_INTERVAL) + ) + ); + final ApiKeyService service = new ApiKeyService( + settings, + clock, + client, + securityIndex, + ClusterServiceUtils.createClusterService(threadPool, clusterSettings), + cacheInvalidatorRegistry, + threadPool, + MeterRegistry.NOOP, + customFeatureService // Use the provided FeatureService + ); + if ("0s".equals(settings.get(ApiKeyService.CACHE_TTL_SETTING.getKey()))) { + verify(cacheInvalidatorRegistry, never()).registerCacheInvalidator(eq("api_key"), any()); + } else { + verify(cacheInvalidatorRegistry).registerCacheInvalidator(eq("api_key"), any()); + } + return service; + } + private ApiKeyService createApiKeyService() { final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build(); return createApiKeyService(settings); @@ -3339,7 +3494,8 @@ private ApiKeyService createApiKeyService(Settings baseSettings, MeterRegistry m ClusterServiceUtils.createClusterService(threadPool, clusterSettings), cacheInvalidatorRegistry, threadPool, - meterRegistry + meterRegistry, + mock(FeatureService.class) ); if ("0s".equals(settings.get(ApiKeyService.CACHE_TTL_SETTING.getKey()))) { verify(cacheInvalidatorRegistry, never()).registerCacheInvalidator(eq("api_key"), any()); @@ -3426,7 +3582,8 @@ private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean inval "metadata", Map.of() ), - metadataBytes + metadataBytes, + null ); } @@ -3458,6 +3615,68 @@ private ApiKey.Type parseTypeFromSourceMap(Map sourceMap) { } } + private ApiKeyDoc createCrossClusterApiKeyDocWithCertificateIdentity(String certificateIdentity) throws IOException { + final String apiKey = randomAlphaOfLength(16); + final char[] hash = getFastStoredHashAlgoForTests().hash(new SecureString(apiKey.toCharArray())); + + return new ApiKeyDoc( + "api_key", + ApiKey.Type.CROSS_CLUSTER, + Instant.now().toEpochMilli(), + -1L, + false, + -1L, + new String(hash), + "test_key", + ApiKey.CURRENT_API_KEY_VERSION.version(), + new BytesArray("{}"), + new BytesArray("{}"), + createTestCreatorMap(), + null, + certificateIdentity + ); + } + + private Map createTestCreatorMap() { + final User user = new User("test-user", new String[0], "Test User", "test@example.com", Map.of("key", "value"), true); + return Map.of( + "principal", + user.principal(), + "full_name", + user.fullName(), + "email", + user.email(), + "metadata", + user.metadata(), + "realm", + "file", + "realm_type", + "file" + ); + } + + private Authentication createTestAuthentication() { + final User user = new User("test-user", new String[0], "Test User", "test@example.com", Map.of("key", "value"), true); + return AuthenticationTestHelper.builder().user(user).realmRef(new RealmRef("file", "file", "node-1")).build(false); + } + + private static BaseBulkUpdateApiKeyRequest createUpdateRequestWithCertificateIdentity( + final String apiKeyId, + final CertificateIdentity certificateIdentity, + final Map metadata + ) { + return new BaseBulkUpdateApiKeyRequest(List.of(apiKeyId), null, metadata, null, certificateIdentity) { + @Override + public ApiKey.Type getType() { + return ApiKey.Type.CROSS_CLUSTER; + } + }; + } + + private Map extractDocumentContent(XContentBuilder builder) throws IOException { + return XContentHelper.convertToMap(BytesReference.bytes(builder), false, XContentType.JSON).v2(); + } + private static Authenticator.Context getAuthenticatorContext(ThreadContext threadContext) { return new Authenticator.Context( threadContext, diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 64bd33bfcbffc..20daf13a45ac6 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -42,6 +42,7 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.features.FeatureService; import org.elasticsearch.index.get.GetResult; import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.license.License; @@ -340,7 +341,8 @@ public void init() throws Exception { clusterService, mock(CacheInvalidatorRegistry.class), threadPool, - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ); tokenService = new TokenService( settings, diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java index 7e9c4b861cbc9..260702cb36fa0 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java @@ -20,6 +20,7 @@ import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.features.FeatureService; import org.elasticsearch.license.License; import org.elasticsearch.license.TestUtils; import org.elasticsearch.license.internal.XPackLicenseStatus; @@ -140,7 +141,8 @@ public void setupMocks() throws Exception { clusterService, mock(CacheInvalidatorRegistry.class), threadPool, - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ); final ServiceAccountService serviceAccountService = mock(ServiceAccountService.class); doAnswer(invocationOnMock -> { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java index ebc48f682d3a6..e34c70ecc3a75 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java @@ -51,6 +51,7 @@ import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.CheckedRunnable; import org.elasticsearch.core.Nullable; +import org.elasticsearch.features.FeatureService; import org.elasticsearch.index.IndexVersion; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.license.LicenseStateListener; @@ -2653,7 +2654,8 @@ public void testApiKeyAuthUsesApiKeyService() throws Exception { clusterService, mock(CacheInvalidatorRegistry.class), mock(ThreadPool.class), - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ) ); NativePrivilegeStore nativePrivStore = mock(NativePrivilegeStore.class); @@ -2737,7 +2739,8 @@ public void testApiKeyAuthUsesApiKeyServiceWithScopedRole() throws Exception { clusterService, mock(CacheInvalidatorRegistry.class), mock(ThreadPool.class), - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ) ); NativePrivilegeStore nativePrivStore = mock(NativePrivilegeStore.class); @@ -2838,7 +2841,8 @@ public void testGetRoleForCrossClusterAccessAuthentication() throws Exception { clusterService, mock(CacheInvalidatorRegistry.class), mock(ThreadPool.class), - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ) ); final NativePrivilegeStore nativePrivStore = mock(NativePrivilegeStore.class); @@ -2997,7 +3001,8 @@ public void testGetRoleForWorkflowWithRestriction() { clusterService, mock(CacheInvalidatorRegistry.class), mock(ThreadPool.class), - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ); final NativePrivilegeStore privilegeStore = mock(NativePrivilegeStore.class); doAnswer((invocationOnMock) -> { @@ -3113,7 +3118,8 @@ public void testGetRoleForWorkflowWithoutRestriction() { clusterService, mock(CacheInvalidatorRegistry.class), mock(ThreadPool.class), - MeterRegistry.NOOP + MeterRegistry.NOOP, + mock(FeatureService.class) ); final NativePrivilegeStore privilegeStore = mock(NativePrivilegeStore.class); doAnswer((invocationOnMock) -> { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java index 2f6f7a921095f..65ef90d0b457e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java @@ -1414,6 +1414,7 @@ private static ApiKey createApiKeyForOwner(String apiKeyId, String username, Str null ) ), + null, null ); } 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 index 812354986d5bc..9b30a8fae821c 100644 --- 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 @@ -24,6 +24,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.Security; +import org.junit.Assert; import org.mockito.ArgumentCaptor; import java.util.List; @@ -126,9 +127,41 @@ public void sendResponse(RestResponse restResponse) { final RestResponse restResponse = responseSetOnce.get(); assertThat(restResponse.status().getStatus(), equalTo(403)); + Assert.assertNotNull(restResponse.content()); assertThat( restResponse.content().utf8ToString(), containsString("current license is non-compliant for [advanced-remote-cluster-security]") ); } + + public void testCreateKeyWithCertificateIdentity() throws Exception { + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent(new BytesArray(""" + { + "name": "my-cert-key", + "access": { + "search": [ + { + "names": [ + "logs" + ] + } + ] + }, + "certificate_identity": "CN=test,OU=engineering,DC=example,DC=com" + }"""), XContentType.JSON).build(); + + 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(); + Assert.assertNotNull(request.getCertificateIdentity()); + assertThat(request.getCertificateIdentity().value(), equalTo("CN=test,OU=engineering,DC=example,DC=com")); + assertThat(request.getType(), is(ApiKey.Type.CROSS_CLUSTER)); + assertThat(request.getName(), equalTo("my-cert-key")); + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java index d258adfff6ef7..2f5585afb3465 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java @@ -356,7 +356,8 @@ private ApiKey randomApiKeyInfo(boolean withLimitedBy) { "realm-type-1", metadata, roleDescriptors, - limitedByRoleDescriptors + limitedByRoleDescriptors, + null ); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java index 9ae1d32b9e12f..5793f4097e400 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java @@ -310,6 +310,7 @@ public void sendResponse(RestResponse restResponse) { null, null, null, + null, null ); final List profileUids; 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 index f2fe28b2a936f..03862c6c9173a 100644 --- 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 @@ -25,6 +25,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.UpdateCrossClusterApiKeyRequest; import org.elasticsearch.xpack.security.Security; +import org.junit.Assert; import org.mockito.ArgumentCaptor; import java.util.List; @@ -34,6 +35,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -110,4 +112,87 @@ public void sendResponse(RestResponse restResponse) { containsString("current license is non-compliant for [advanced-remote-cluster-security]") ); } + + public void testUpdateWithValidCertificateIdentity() throws Exception { + final String id = randomAlphaOfLength(10); + final String access = randomCrossClusterApiKeyAccessField(); + final String certificateIdentity = "CN=test,OU=engineering,DC=example,DC=com"; + + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent( + new BytesArray(Strings.format(""" + { + "access": %s, + "certificate_identity": "%s" + }""", access, certificateIdentity)), + XContentType.JSON + ).withParams(Map.of("id", id)).build(); + + 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(); + Assert.assertNotNull(request.getCertificateIdentity()); + assertThat(request.getCertificateIdentity().value(), equalTo(certificateIdentity)); + } + + public void testUpdateWithExplicitNullCertificateIdentity() throws Exception { + final String id = randomAlphaOfLength(10); + final String access = randomCrossClusterApiKeyAccessField(); + + // Request with an explicit null for certificate_identity. This indicates that the user wants to + // remove an associated certificate identity from a Cross Cluster API Key. + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent( + new BytesArray(Strings.format(""" + { + "access": %s, + "certificate_identity": null + }""", access)), + XContentType.JSON + ).withParams(Map.of("id", id)).build(); + + 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(); + // Verify that certificate identity wrapper exists but contains null value + assertThat(request.getCertificateIdentity(), is(not(nullValue()))); + assertThat(request.getCertificateIdentity().value(), is(nullValue())); + } + + public void testUpdateWithoutCertificateIdentityField() throws Exception { + final String id = randomAlphaOfLength(10); + final String access = randomCrossClusterApiKeyAccessField(); + + final FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withContent( + new BytesArray(Strings.format(""" + { + "access": %s, + "metadata": {"key": "value"} + }""", access)), + XContentType.JSON + ).withParams(Map.of("id", id)).build(); + + 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(); + // Verify that certificate identity is completely null (field omitted) + assertThat(request.getCertificateIdentity(), is(nullValue())); + } + } diff --git a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/AbstractUpgradeTestCase.java b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/AbstractUpgradeTestCase.java index 60d3d69e3b3e9..d86a61a6f6ac9 100644 --- a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/AbstractUpgradeTestCase.java +++ b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/AbstractUpgradeTestCase.java @@ -6,6 +6,7 @@ */ package org.elasticsearch.upgrades; +import org.apache.http.HttpHost; import org.elasticsearch.Build; import org.elasticsearch.client.Request; import org.elasticsearch.client.Response; @@ -17,15 +18,22 @@ import org.elasticsearch.core.Booleans; import org.elasticsearch.test.XContentTestUtils; import org.elasticsearch.test.rest.ESRestTestCase; +import org.elasticsearch.test.rest.ObjectPath; import org.elasticsearch.xpack.test.SecuritySettingsSourceField; import org.junit.Before; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; +import static org.hamcrest.Matchers.notNullValue; + public abstract class AbstractUpgradeTestCase extends ESRestTestCase { private static final String BASIC_AUTH_VALUE = basicAuthHeaderValue( @@ -41,6 +49,9 @@ protected static boolean isOriginalCluster(String clusterVersion) { return UPGRADE_FROM_VERSION.equals(clusterVersion); } + protected RestClient oldVersionClient = null; + protected RestClient newVersionClient = null; + /** * Upgrade tests by design are also executed with the same version. We might want to skip some checks if that's the case, see * for example gh#39102. @@ -170,4 +181,57 @@ protected static void waitForSecurityMigrationCompletion(RestClient adminClient, assertTrue(Integer.parseInt(responseVersion) >= version); }); } + + protected void closeClientsByVersion() throws IOException { + if (oldVersionClient != null) { + oldVersionClient.close(); + oldVersionClient = null; + } + if (newVersionClient != null) { + newVersionClient.close(); + newVersionClient = null; + } + } + + @SuppressWarnings("unchecked") + protected Map getRestClientByCapability(Function, Boolean> capabilityChecker) + throws IOException { + Response response = client().performRequest(new Request("GET", "_nodes")); + assertOK(response); + ObjectPath objectPath = ObjectPath.createFromResponse(response); + Map nodesAsMap = objectPath.evaluate("nodes"); + Map> hostsByCapability = new HashMap<>(); + + for (Map.Entry entry : nodesAsMap.entrySet()) { + Map nodeDetails = (Map) entry.getValue(); + var capabilitySupported = capabilityChecker.apply(nodeDetails); + Map httpInfo = (Map) nodeDetails.get("http"); + hostsByCapability.computeIfAbsent(capabilitySupported, k -> new ArrayList<>()) + .add(HttpHost.create((String) httpInfo.get("publish_address"))); + } + + Map clientsByCapability = new HashMap<>(); + for (var entry : hostsByCapability.entrySet()) { + clientsByCapability.put(entry.getKey(), buildClient(restClientSettings(), entry.getValue().toArray(new HttpHost[0]))); + } + return clientsByCapability; + } + + protected void createClientsByCapability(Function, Boolean> capabilityChecker) throws IOException { + var clientsByCapability = getRestClientByCapability(capabilityChecker); + if (clientsByCapability.size() == 2) { + for (Map.Entry client : clientsByCapability.entrySet()) { + if (client.getKey() == false) { + oldVersionClient = client.getValue(); + } else { + newVersionClient = client.getValue(); + } + } + assertThat(oldVersionClient, notNullValue()); + assertThat(newVersionClient, notNullValue()); + } else { + fail("expected 2 versions during rolling upgrade but got: " + clientsByCapability.size()); + } + } + } diff --git a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java index 3ed2a68d0129e..715d0eaaaf0a3 100644 --- a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java +++ b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java @@ -7,7 +7,6 @@ package org.elasticsearch.upgrades; -import org.apache.http.HttpHost; import org.apache.http.client.methods.HttpGet; import org.elasticsearch.Build; import org.elasticsearch.TransportVersion; @@ -31,9 +30,7 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Base64; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -57,9 +54,6 @@ public class ApiKeyBackwardsCompatibilityIT extends AbstractUpgradeTestCase { private static final Version UPGRADE_FROM_VERSION = Version.fromString(System.getProperty("tests.upgrade_from_version")); - private RestClient oldVersionClient = null; - private RestClient newVersionClient = null; - public void testQueryRestTypeKeys() throws IOException { assumeTrue( "only API keys created pre-8.9 are relevant for the rest-type query bwc case", @@ -131,7 +125,7 @@ public void testCreatingAndUpdatingApiKeys() throws Exception { } case MIXED -> { try { - this.createClientsByVersion(); + this.createClientsByCapability(this::nodeSupportApiKeyRemoteIndices); // succeed when remote_indices are not provided final boolean includeRoles = randomBoolean(); final String initialApiKeyRole = includeRoles ? randomRoleDescriptors(false) : "{}"; @@ -206,6 +200,62 @@ public void testCreatingAndUpdatingApiKeys() throws Exception { } } + public void testCertificateIdentityBackwardsCompatibility() throws Exception { + assumeTrue( + "certificate identity backwards compatibility only relevant when upgrading from pre-9.2.0", + UPGRADE_FROM_VERSION.before(Version.V_9_2_0) + ); + switch (CLUSTER_TYPE) { + case OLD -> { + var exception = expectThrows(Exception.class, () -> createCrossClusterApiKeyWithCertIdentity("CN=test-.*")); + assertThat( + exception.getMessage(), + anyOf(containsString("unknown field [certificate_identity]"), containsString("certificate_identity not supported")) + ); + } + case MIXED -> { + try { + this.createClientsByCapability(this::nodeSupportsCertificateIdentity); + + // Test against old node - should get parsing error + Exception oldNodeException = expectThrows( + Exception.class, + () -> createCrossClusterApiKeyWithCertIdentity(oldVersionClient, "CN=test-.*") + ); + assertThat( + oldNodeException.getMessage(), + anyOf(containsString("unknown field [certificate_identity]"), containsString("certificate_identity not supported")) + ); + + // Test against new node - should get mixed-version error + Exception newNodeException = expectThrows( + Exception.class, + () -> createCrossClusterApiKeyWithCertIdentity(newVersionClient, "CN=test-.*") + ); + assertThat( + newNodeException.getMessage(), + containsString("cluster is in a mixed-version state and does not yet support the [certificate_identity] field") + ); + } finally { + this.closeClientsByVersion(); + } + } + case UPGRADED -> { + // Fully upgraded cluster should support certificate identity + final Tuple apiKey = createCrossClusterApiKeyWithCertIdentity("CN=test-.*"); + + // Verify the API key was created with certificate identity + final Request getApiKeyRequest = new Request("GET", "/_security/api_key"); + getApiKeyRequest.addParameter("id", apiKey.v1()); + final Response getResponse = client().performRequest(getApiKeyRequest); + assertOK(getResponse); + + final ObjectPath getPath = ObjectPath.createFromResponse(getResponse); + assertThat(getPath.evaluate("api_keys.0.certificate_identity"), equalTo("CN=test-.*")); + } + } + } + private Tuple createOrGrantApiKey(String roles) throws IOException { return createOrGrantApiKey(client(), roles); } @@ -341,55 +391,6 @@ boolean nodeSupportApiKeyRemoteIndices(Map nodeDetails) { return transportVersion.onOrAfter(RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY); } - private void createClientsByVersion() throws IOException { - var clientsByCapability = getRestClientByCapability(); - if (clientsByCapability.size() == 2) { - for (Map.Entry client : clientsByCapability.entrySet()) { - if (client.getKey() == false) { - oldVersionClient = client.getValue(); - } else { - newVersionClient = client.getValue(); - } - } - assertThat(oldVersionClient, notNullValue()); - assertThat(newVersionClient, notNullValue()); - } else { - fail("expected 2 versions during rolling upgrade but got: " + clientsByCapability.size()); - } - } - - private void closeClientsByVersion() throws IOException { - if (oldVersionClient != null) { - oldVersionClient.close(); - oldVersionClient = null; - } - if (newVersionClient != null) { - newVersionClient.close(); - newVersionClient = null; - } - } - - @SuppressWarnings("unchecked") - private Map getRestClientByCapability() throws IOException { - Response response = client().performRequest(new Request("GET", "_nodes")); - assertOK(response); - ObjectPath objectPath = ObjectPath.createFromResponse(response); - Map nodesAsMap = objectPath.evaluate("nodes"); - Map> hostsByCapability = new HashMap<>(); - for (Map.Entry entry : nodesAsMap.entrySet()) { - Map nodeDetails = (Map) entry.getValue(); - var capabilitySupported = nodeSupportApiKeyRemoteIndices(nodeDetails); - Map httpInfo = (Map) nodeDetails.get("http"); - hostsByCapability.computeIfAbsent(capabilitySupported, k -> new ArrayList<>()) - .add(HttpHost.create((String) httpInfo.get("publish_address"))); - } - Map clientsByCapability = new HashMap<>(); - for (var entry : hostsByCapability.entrySet()) { - clientsByCapability.put(entry.getKey(), buildClient(restClientSettings(), entry.getValue().toArray(new HttpHost[0]))); - } - return clientsByCapability; - } - private static RoleDescriptor randomRoleDescriptor(boolean includeRemoteDescriptors) { final Set excludedPrivileges = Set.of( "read_failure_store", @@ -424,4 +425,43 @@ private void assertQuery(RestClient restClient, String body, Consumer> apiKeys = (List>) responseMap.get("api_keys"); apiKeysVerifier.accept(apiKeys); } + + private boolean nodeSupportsCertificateIdentity(Map nodeDetails) { + String nodeVersionString = (String) nodeDetails.get("version"); + Version nodeVersion = Version.fromString(nodeVersionString); + // Certificate identity was introduced in 9.3.0 + return nodeVersion.onOrAfter(Version.V_9_3_0); + } + + private Tuple createCrossClusterApiKeyWithCertIdentity(String certificateIdentity) throws IOException { + return createCrossClusterApiKeyWithCertIdentity(client(), certificateIdentity); + } + + private Tuple createCrossClusterApiKeyWithCertIdentity(RestClient client, String certificateIdentity) + throws IOException { + final String name = "test-cc-api-key-" + randomAlphaOfLengthBetween(3, 5); + final Request createApiKeyRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createApiKeyRequest.setJsonEntity(Strings.format(""" + { + "name": "%s", + "certificate_identity": "%s", + "access": { + "search": [ + { + "names": ["test-*"] + } + ] + } + }""", name, certificateIdentity)); + + final Response createResponse = client.performRequest(createApiKeyRequest); + assertOK(createResponse); + final ObjectPath path = ObjectPath.createFromResponse(createResponse); + final String id = path.evaluate("id"); + final String key = path.evaluate("api_key"); + assertThat(id, notNullValue()); + assertThat(key, notNullValue()); + return Tuple.tuple(id, key); + } + } diff --git a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/RolesBackwardsCompatibilityIT.java b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/RolesBackwardsCompatibilityIT.java index c2d27b8cb5168..191025a7c7ed6 100644 --- a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/RolesBackwardsCompatibilityIT.java +++ b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/RolesBackwardsCompatibilityIT.java @@ -7,23 +7,17 @@ package org.elasticsearch.upgrades; -import org.apache.http.HttpHost; import org.elasticsearch.Build; import org.elasticsearch.TransportVersion; import org.elasticsearch.TransportVersions; import org.elasticsearch.client.Request; -import org.elasticsearch.client.Response; import org.elasticsearch.client.RestClient; import org.elasticsearch.test.XContentTestUtils; -import org.elasticsearch.test.rest.ObjectPath; import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import java.io.IOException; import java.io.UncheckedIOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Set; @@ -35,13 +29,9 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.notNullValue; public class RolesBackwardsCompatibilityIT extends AbstractUpgradeTestCase { - private RestClient oldVersionClient = null; - private RestClient newVersionClient = null; - public void testRolesWithDescription() throws Exception { assumeTrue( "The role description is supported after transport version: " + SECURITY_ROLE_DESCRIPTION, @@ -76,7 +66,8 @@ public void testRolesWithDescription() throws Exception { } case MIXED -> { try { - this.createClientsByVersion(SECURITY_ROLE_DESCRIPTION); + this.createClientsByCapability(node -> nodeSupportTransportVersion(node, RoleDescriptor.SECURITY_ROLE_DESCRIPTION)); + // succeed when role description is not provided final String initialRole = randomRoleDescriptorSerialized(); createRole(client(), "my-valid-mixed-role", initialRole); @@ -190,7 +181,7 @@ public void testRolesWithManageRoles() throws Exception { } case MIXED -> { try { - this.createClientsByVersion(TransportVersions.V_8_16_0); + this.createClientsByCapability(node -> nodeSupportTransportVersion(node, TransportVersions.V_8_16_0)); // succeed when role manage roles is not provided final String initialRole = randomRoleDescriptorSerialized(); createRole(client(), "my-valid-mixed-role", initialRole); @@ -330,55 +321,6 @@ private boolean nodeSupportTransportVersion(Map nodeDetails, Tra return nodeTransportVersion.onOrAfter(transportVersion); } - private void createClientsByVersion(TransportVersion transportVersion) throws IOException { - var clientsByCapability = getRestClientByCapability(transportVersion); - if (clientsByCapability.size() == 2) { - for (Map.Entry client : clientsByCapability.entrySet()) { - if (client.getKey() == false) { - oldVersionClient = client.getValue(); - } else { - newVersionClient = client.getValue(); - } - } - assertThat(oldVersionClient, notNullValue()); - assertThat(newVersionClient, notNullValue()); - } else { - fail("expected 2 versions during rolling upgrade but got: " + clientsByCapability.size()); - } - } - - private void closeClientsByVersion() throws IOException { - if (oldVersionClient != null) { - oldVersionClient.close(); - oldVersionClient = null; - } - if (newVersionClient != null) { - newVersionClient.close(); - newVersionClient = null; - } - } - - @SuppressWarnings("unchecked") - private Map getRestClientByCapability(TransportVersion transportVersion) throws IOException { - Response response = client().performRequest(new Request("GET", "_nodes")); - assertOK(response); - ObjectPath objectPath = ObjectPath.createFromResponse(response); - Map nodesAsMap = objectPath.evaluate("nodes"); - Map> hostsByCapability = new HashMap<>(); - for (Map.Entry entry : nodesAsMap.entrySet()) { - Map nodeDetails = (Map) entry.getValue(); - var capabilitySupported = nodeSupportTransportVersion(nodeDetails, transportVersion); - Map httpInfo = (Map) nodeDetails.get("http"); - hostsByCapability.computeIfAbsent(capabilitySupported, k -> new ArrayList<>()) - .add(HttpHost.create((String) httpInfo.get("publish_address"))); - } - Map clientsByCapability = new HashMap<>(); - for (var entry : hostsByCapability.entrySet()) { - clientsByCapability.put(entry.getKey(), buildClient(restClientSettings(), entry.getValue().toArray(new HttpHost[0]))); - } - return clientsByCapability; - } - private static RoleDescriptor randomRoleDescriptor(boolean includeDescription, boolean includeManageRoles) { final Set excludedPrivileges = Set.of( "cross_cluster_replication", diff --git a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/TokenBackwardsCompatibilityIT.java b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/TokenBackwardsCompatibilityIT.java index 02dc679152bf4..3423dc2f89fbb 100644 --- a/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/TokenBackwardsCompatibilityIT.java +++ b/x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/TokenBackwardsCompatibilityIT.java @@ -57,7 +57,7 @@ private void collectClientsByVersion() throws IOException { } @After - private void closeClientsByVersion() throws IOException { + protected void cleanUpClients() throws IOException { for (RestClient client : twoClients) { client.close(); }