diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index d83a4992f97b0..59bc234be2d85 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -286,6 +286,7 @@ static TransportVersion def(int id) { public static final TransportVersion ILM_ADD_SKIP_SETTING = def(9_089_0_00); public static final TransportVersion ML_INFERENCE_MISTRAL_CHAT_COMPLETION_ADDED = def(9_090_0_00); public static final TransportVersion IDP_CUSTOM_SAML_ATTRIBUTES_ALLOW_LIST = def(9_091_0_00); + public static final TransportVersion SECURITY_CLOUD_API_KEY_REALM_AND_TYPE = def(9_092_0_00); /* * STOP! READ THIS FIRST! No, really, diff --git a/x-pack/plugin/core/src/main/java/module-info.java b/x-pack/plugin/core/src/main/java/module-info.java index c6f8376d63fa9..3d28eff88a24a 100644 --- a/x-pack/plugin/core/src/main/java/module-info.java +++ b/x-pack/plugin/core/src/main/java/module-info.java @@ -230,6 +230,7 @@ exports org.elasticsearch.xpack.core.watcher.trigger; exports org.elasticsearch.xpack.core.watcher.watch; exports org.elasticsearch.xpack.core.watcher; + exports org.elasticsearch.xpack.core.security.authc.apikey; provides org.elasticsearch.action.admin.cluster.node.info.ComponentVersionNumber with diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 9226a4148900d..f41b19de95272 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -16,6 +16,7 @@ import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.core.security.authc.AuthenticationFailureHandler; import org.elasticsearch.xpack.core.security.authc.Realm; +import org.elasticsearch.xpack.core.security.authc.apikey.CustomApiKeyAuthenticator; import org.elasticsearch.xpack.core.security.authc.service.NodeLocalServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; @@ -128,6 +129,10 @@ default ServiceAccountTokenStore getServiceAccountTokenStore(SecurityComponents return null; } + default CustomApiKeyAuthenticator getCustomApiKeyAuthenticator(SecurityComponents components) { + return null; + } + /** * Returns a authorization engine for authorizing requests, or null to use the default authorization mechanism. * diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java index 561a94dd3cc05..fdfe21cd5ab33 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java @@ -61,6 +61,7 @@ import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newAnonymousRealmRef; import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newApiKeyRealmRef; +import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newCloudApiKeyRealmRef; import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newCrossClusterAccessRealmRef; import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newInternalAttachRealmRef; import static org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef.newInternalFallbackRealmRef; @@ -71,6 +72,8 @@ import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_REALM_TYPE; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.ATTACH_REALM_NAME; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.ATTACH_REALM_TYPE; +import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.CLOUD_API_KEY_REALM_NAME; +import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.CLOUD_API_KEY_REALM_TYPE; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.CROSS_CLUSTER_ACCESS_AUTHENTICATION_KEY; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.CROSS_CLUSTER_ACCESS_REALM_NAME; import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.CROSS_CLUSTER_ACCESS_REALM_TYPE; @@ -264,6 +267,16 @@ public Authentication maybeRewriteForOlderVersion(TransportVersion olderVersion) + "]" ); } + if (isCloudApiKey() && olderVersion.before(TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE)) { + throw new IllegalArgumentException( + "versions of Elasticsearch before [" + + TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE.toReleaseVersion() + + "] can't handle cloud API key authentication and attempted to rewrite for [" + + olderVersion.toReleaseVersion() + + "]" + ); + } + final Map newMetadata = maybeRewriteMetadata(olderVersion, this); final Authentication newAuthentication; @@ -528,6 +541,10 @@ public boolean isApiKey() { return effectiveSubject.getType() == Subject.Type.API_KEY; } + public boolean isCloudApiKey() { + return effectiveSubject.getType() == Subject.Type.CLOUD_API_KEY; + } + public boolean isCrossClusterAccess() { return effectiveSubject.getType() == Subject.Type.CROSS_CLUSTER_ACCESS; } @@ -625,6 +642,16 @@ private static void doWriteTo(Subject effectiveSubject, Subject authenticatingSu + "]" ); } + if (effectiveSubject.getType() == Subject.Type.CLOUD_API_KEY + && out.getTransportVersion().before(TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE)) { + throw new IllegalArgumentException( + "versions of Elasticsearch before [" + + TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE.toReleaseVersion() + + "] can't handle cloud API key authentication and attempted to send to [" + + out.getTransportVersion().toReleaseVersion() + + "]" + ); + } final boolean isRunAs = authenticatingSubject != effectiveSubject; if (isRunAs) { final User outerUser = effectiveSubject.getUser(); @@ -772,6 +799,7 @@ public void toXContentFragment(XContentBuilder builder) throws IOException { builder.field("api_key", Map.of("id", apiKeyId, "name", apiKeyName)); } } + // TODO cloud API key fields such as managed_by } public static Authentication getAuthenticationFromCrossClusterAccessMetadata(Authentication authentication) { @@ -891,11 +919,17 @@ private void checkConsistencyForInternalAuthenticationType() { private void checkConsistencyForApiKeyAuthenticationType() { final RealmRef authenticatingRealm = authenticatingSubject.getRealm(); - if (false == authenticatingRealm.isApiKeyRealm() && false == authenticatingRealm.isCrossClusterAccessRealm()) { + if (false == authenticatingRealm.isApiKeyRealm() + && false == authenticatingRealm.isCrossClusterAccessRealm() + && false == authenticatingRealm.isCloudApiKeyRealm()) { throw new IllegalArgumentException( Strings.format("API key authentication cannot have realm type [%s]", authenticatingRealm.type) ); } + if (authenticatingRealm.isCloudApiKeyRealm()) { + // TODO consistency check for cloud API keys + return; + } checkConsistencyForApiKeyAuthenticatingSubject("API key"); if (Subject.Type.CROSS_CLUSTER_ACCESS == authenticatingSubject.getType()) { if (authenticatingSubject.getMetadata().get(CROSS_CLUSTER_ACCESS_AUTHENTICATION_KEY) == null) { @@ -1183,6 +1217,10 @@ private boolean isAnonymousRealm() { return ANONYMOUS_REALM_NAME.equals(name) && ANONYMOUS_REALM_TYPE.equals(type); } + private boolean isCloudApiKeyRealm() { + return CLOUD_API_KEY_REALM_NAME.equals(name) && CLOUD_API_KEY_REALM_TYPE.equals(type); + } + private boolean isApiKeyRealm() { return API_KEY_REALM_NAME.equals(name) && API_KEY_REALM_TYPE.equals(type); } @@ -1212,6 +1250,11 @@ static RealmRef newServiceAccountRealmRef(String nodeName) { return new Authentication.RealmRef(ServiceAccountSettings.REALM_NAME, ServiceAccountSettings.REALM_TYPE, nodeName, null); } + static RealmRef newCloudApiKeyRealmRef(String nodeName) { + // no domain for cloud API key tokens + return new RealmRef(CLOUD_API_KEY_REALM_NAME, CLOUD_API_KEY_REALM_TYPE, nodeName, null); + } + static RealmRef newApiKeyRealmRef(String nodeName) { // no domain for API Key tokens return new RealmRef(API_KEY_REALM_NAME, API_KEY_REALM_TYPE, nodeName, null); @@ -1293,6 +1336,16 @@ public static Authentication newRealmAuthentication(User user, RealmRef realmRef return authentication; } + public static Authentication newCloudApiKeyAuthentication(AuthenticationResult authResult, String nodeName) { + assert authResult.isAuthenticated() : "cloud API Key authn result must be successful"; + final User apiKeyUser = authResult.getValue(); + final Authentication.RealmRef authenticatedBy = newCloudApiKeyRealmRef(nodeName); + return new Authentication( + new Subject(apiKeyUser, authenticatedBy, TransportVersion.current(), authResult.getMetadata()), + AuthenticationType.API_KEY + ); + } + public static Authentication newApiKeyAuthentication(AuthenticationResult authResult, String nodeName) { assert authResult.isAuthenticated() : "API Key authn result must be successful"; final User apiKeyUser = authResult.getValue(); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java index 37cf09bc4607a..7e6da3a82baa6 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java @@ -13,6 +13,9 @@ public final class AuthenticationField { public static final String PRIVILEGE_CATEGORY_VALUE_OPERATOR = "operator"; public static final String PRIVILEGE_CATEGORY_VALUE_EMPTY = "__empty"; + public static final String CLOUD_API_KEY_REALM_NAME = "_cloud_api_key"; + public static final String CLOUD_API_KEY_REALM_TYPE = "_cloud_api_key"; + public static final String API_KEY_REALM_NAME = "_es_api_key"; public static final String API_KEY_REALM_TYPE = "_es_api_key"; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Subject.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Subject.java index 39173be73f191..ff67108796bff 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Subject.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Subject.java @@ -47,6 +47,7 @@ public class Subject { public enum Type { USER, API_KEY, + CLOUD_API_KEY, SERVICE_ACCOUNT, CROSS_CLUSTER_ACCESS, } @@ -72,6 +73,9 @@ public Subject(User user, Authentication.RealmRef realm, TransportVersion versio } else if (AuthenticationField.API_KEY_REALM_TYPE.equals(realm.getType())) { assert AuthenticationField.API_KEY_REALM_NAME.equals(realm.getName()) : "api key realm name mismatch"; this.type = Type.API_KEY; + } else if (AuthenticationField.CLOUD_API_KEY_REALM_TYPE.equals(realm.getType())) { + assert AuthenticationField.CLOUD_API_KEY_REALM_NAME.equals(realm.getName()) : "cloud api key realm name mismatch"; + this.type = Type.CLOUD_API_KEY; } else if (ServiceAccountSettings.REALM_TYPE.equals(realm.getType())) { assert ServiceAccountSettings.REALM_NAME.equals(realm.getName()) : "service account realm name mismatch"; this.type = Type.SERVICE_ACCOUNT; @@ -105,19 +109,12 @@ public TransportVersion getTransportVersion() { } public RoleReferenceIntersection getRoleReferenceIntersection(@Nullable AnonymousUser anonymousUser) { - switch (type) { - case USER: - return buildRoleReferencesForUser(anonymousUser); - case API_KEY: - return buildRoleReferencesForApiKey(); - case SERVICE_ACCOUNT: - return new RoleReferenceIntersection(new RoleReference.ServiceAccountRoleReference(user.principal())); - case CROSS_CLUSTER_ACCESS: - return buildRoleReferencesForCrossClusterAccess(); - default: - assert false : "unknown subject type: [" + type + "]"; - throw new IllegalStateException("unknown subject type: [" + type + "]"); - } + return switch (type) { + case CLOUD_API_KEY, USER -> buildRoleReferencesForUser(anonymousUser); + case API_KEY -> buildRoleReferencesForApiKey(); + case SERVICE_ACCOUNT -> new RoleReferenceIntersection(new RoleReference.ServiceAccountRoleReference(user.principal())); + case CROSS_CLUSTER_ACCESS -> buildRoleReferencesForCrossClusterAccess(); + }; } public boolean canAccessResourcesOf(Subject resourceCreatorSubject) { diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/apikey/CustomApiKeyAuthenticator.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/apikey/CustomApiKeyAuthenticator.java new file mode 100644 index 0000000000000..4f5d05e720715 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/apikey/CustomApiKeyAuthenticator.java @@ -0,0 +1,51 @@ +/* + * 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.authc.apikey; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; + +/** + * An extension point to provide a custom API key authenticator implementation. + * The implementation is wrapped by a core `Authenticator` class and included in the authenticator chain _before_ the + * default API key authenticator. + */ +public interface CustomApiKeyAuthenticator { + String name(); + + AuthenticationToken extractCredentials(@Nullable SecureString apiKeyCredentials); + + void authenticate(@Nullable AuthenticationToken authenticationToken, ActionListener> listener); + + /** + * A no-op implementation of {@link CustomApiKeyAuthenticator} that is effectively skipped in the authenticator chain. + */ + class Noop implements CustomApiKeyAuthenticator { + @Override + public String name() { + return "noop"; + } + + @Override + public AuthenticationToken extractCredentials(@Nullable SecureString apiKeyCredentials) { + return null; + } + + @Override + public void authenticate( + @Nullable AuthenticationToken authenticationToken, + ActionListener> listener + ) { + listener.onResponse(AuthenticationResult.notHandled()); + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/xcontent/XContentUtils.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/xcontent/XContentUtils.java index 9f7c9e9c24156..9656237eb5044 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/xcontent/XContentUtils.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/xcontent/XContentUtils.java @@ -128,6 +128,9 @@ private static void addSubjectInfo(XContentBuilder builder, Subject subject) thr } builder.endObject(); } + case CLOUD_API_KEY -> { + // TODO Add cloud API key information here + } } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationSerializationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationSerializationTests.java index b7c7681c95d03..095a7ed6c59f9 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationSerializationTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationSerializationTests.java @@ -115,6 +115,52 @@ public void testWriteToWithCrossClusterAccessThrowsOnUnsupportedVersion() throws } } + public void testWriteToAndReadFromWithCloudApiKeyAuthentication() throws Exception { + final Authentication authentication = Authentication.newCloudApiKeyAuthentication( + AuthenticationResult.success(new User(randomAlphanumericOfLength(5), "superuser"), Map.of()), + randomAlphanumericOfLength(10) + ); + + assertThat(authentication.isCloudApiKey(), is(true)); + + BytesStreamOutput output = new BytesStreamOutput(); + authentication.writeTo(output); + final Authentication readFrom = new Authentication(output.bytes().streamInput()); + assertThat(readFrom.isCloudApiKey(), is(true)); + + assertThat(readFrom, not(sameInstance(authentication))); + assertThat(readFrom, equalTo(authentication)); + } + + public void testWriteToWithCloudApiKeyThrowsOnUnsupportedVersion() { + final Authentication authentication = Authentication.newCloudApiKeyAuthentication( + AuthenticationResult.success(new User(randomAlphanumericOfLength(5), "superuser"), Map.of()), + randomAlphanumericOfLength(10) + ); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + final TransportVersion version = TransportVersionUtils.randomVersionBetween( + random(), + TransportVersions.V_8_0_0, + TransportVersionUtils.getPreviousVersion(TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE) + ); + out.setTransportVersion(version); + + final var ex = expectThrows(IllegalArgumentException.class, () -> authentication.writeTo(out)); + assertThat( + ex.getMessage(), + containsString( + "versions of Elasticsearch before [" + + TransportVersions.SECURITY_CLOUD_API_KEY_REALM_AND_TYPE.toReleaseVersion() + + "] can't handle cloud API key authentication and attempted to send to [" + + out.getTransportVersion().toReleaseVersion() + + "]" + ) + ); + } + + } + public void testSystemUserReadAndWrite() throws Exception { BytesStreamOutput output = new BytesStreamOutput(); diff --git a/x-pack/plugin/security/src/main/java/module-info.java b/x-pack/plugin/security/src/main/java/module-info.java index 3186a59fe4c0a..80986de66d89a 100644 --- a/x-pack/plugin/security/src/main/java/module-info.java +++ b/x-pack/plugin/security/src/main/java/module-info.java @@ -69,7 +69,7 @@ exports org.elasticsearch.xpack.security.action.settings to org.elasticsearch.server; exports org.elasticsearch.xpack.security.operator to org.elasticsearch.internal.operator, org.elasticsearch.internal.security; exports org.elasticsearch.xpack.security.authz to org.elasticsearch.internal.security; - exports org.elasticsearch.xpack.security.authc to org.elasticsearch.xcontent; + exports org.elasticsearch.xpack.security.authc to org.elasticsearch.xcontent, org.elasticsearch.internal.security; exports org.elasticsearch.xpack.security.authc.saml to org.elasticsearch.internal.security; exports org.elasticsearch.xpack.security.slowlog to org.elasticsearch.server; exports org.elasticsearch.xpack.security.authc.support to org.elasticsearch.internal.security; 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 c3161fc1b40cf..3de93f1d5397d 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 @@ -207,6 +207,7 @@ import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.RealmSettings; import org.elasticsearch.xpack.core.security.authc.Subject; +import org.elasticsearch.xpack.core.security.authc.apikey.CustomApiKeyAuthenticator; import org.elasticsearch.xpack.core.security.authc.service.NodeLocalServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; @@ -1100,6 +1101,10 @@ Collection createComponents( operatorPrivilegesService.set(OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); } + final CustomApiKeyAuthenticator customApiKeyAuthenticator = createCustomApiKeyAuthenticator(extensionComponents); + + components.add(customApiKeyAuthenticator); + authcService.set( new AuthenticationService( settings, @@ -1112,6 +1117,7 @@ Collection createComponents( apiKeyService, serviceAccountService, operatorPrivilegesService.get(), + customApiKeyAuthenticator, telemetryProvider.getMeterRegistry() ) ); @@ -1247,6 +1253,36 @@ Collection createComponents( return components; } + private CustomApiKeyAuthenticator createCustomApiKeyAuthenticator(SecurityExtension.SecurityComponents extensionComponents) { + final SetOnce customApiKeyAuthenticatorSetOnce = new SetOnce<>(); + for (var extension : securityExtensions) { + final CustomApiKeyAuthenticator customApiKeyAuthenticator = extension.getCustomApiKeyAuthenticator(extensionComponents); + if (customApiKeyAuthenticator != null) { + if (false == isInternalExtension(extension)) { + throw new IllegalStateException( + "The [" + + extension.getClass().getName() + + "] extension tried to install a custom CustomApiKeyAuthenticator. " + + "This functionality is not available to external extensions." + ); + } + boolean success = customApiKeyAuthenticatorSetOnce.trySet(customApiKeyAuthenticator); + if (false == success) { + throw new IllegalStateException( + "The [" + + extension.getClass().getName() + + "] extension tried to install a custom CustomApiKeyAuthenticator, but one has already been installed." + ); + } + logger.debug("CustomApiKeyAuthenticator provided by extension [{}]", extension.extensionName()); + } + } + if (customApiKeyAuthenticatorSetOnce.get() == null) { + customApiKeyAuthenticatorSetOnce.set(new CustomApiKeyAuthenticator.Noop()); + } + return customApiKeyAuthenticatorSetOnce.get(); + } + private ServiceAccountService createServiceAccountService( List components, CacheInvalidatorRegistry cacheInvalidatorRegistry, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index b9d3bd099a52b..a9c513a605fe8 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -30,6 +30,7 @@ import org.elasticsearch.xpack.core.security.authc.AuthenticationServiceField; import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; import org.elasticsearch.xpack.core.security.authc.Realm; +import org.elasticsearch.xpack.core.security.authc.apikey.CustomApiKeyAuthenticator; import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.EmptyAuthorizationInfo; import org.elasticsearch.xpack.core.security.user.AnonymousUser; @@ -92,6 +93,7 @@ public AuthenticationService( ApiKeyService apiKeyService, ServiceAccountService serviceAccountService, OperatorPrivilegesService operatorPrivilegesService, + CustomApiKeyAuthenticator customApiKeyAuthenticator, MeterRegistry meterRegistry ) { this.realms = realms; @@ -115,6 +117,7 @@ public AuthenticationService( new AuthenticationContextSerializer(), new ServiceAccountAuthenticator(serviceAccountService, nodeName, meterRegistry), new OAuth2TokenAuthenticator(tokenService, meterRegistry), + new PluggableApiKeyAuthenticator(customApiKeyAuthenticator), new ApiKeyAuthenticator(apiKeyService, nodeName, meterRegistry), new RealmsAuthenticator(numInvalidation, lastSuccessfulAuthCache, meterRegistry) ); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticatorChain.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticatorChain.java index d43909ab8e8d6..f9e255c633dcd 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticatorChain.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticatorChain.java @@ -54,6 +54,7 @@ class AuthenticatorChain { AuthenticationContextSerializer authenticationSerializer, ServiceAccountAuthenticator serviceAccountAuthenticator, OAuth2TokenAuthenticator oAuth2TokenAuthenticator, + PluggableApiKeyAuthenticator pluggableApiKeyAuthenticator, ApiKeyAuthenticator apiKeyAuthenticator, RealmsAuthenticator realmsAuthenticator ) { @@ -64,7 +65,13 @@ class AuthenticatorChain { this.isAnonymousUserEnabled = AnonymousUser.isAnonymousEnabled(settings); this.authenticationSerializer = authenticationSerializer; this.realmsAuthenticator = realmsAuthenticator; - this.allAuthenticators = List.of(serviceAccountAuthenticator, oAuth2TokenAuthenticator, apiKeyAuthenticator, realmsAuthenticator); + this.allAuthenticators = List.of( + serviceAccountAuthenticator, + oAuth2TokenAuthenticator, + pluggableApiKeyAuthenticator, + apiKeyAuthenticator, + realmsAuthenticator + ); } void authenticate(Authenticator.Context context, ActionListener originalListener) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/PluggableApiKeyAuthenticator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/PluggableApiKeyAuthenticator.java new file mode 100644 index 0000000000000..0637efbc5e89a --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/PluggableApiKeyAuthenticator.java @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; +import org.elasticsearch.xpack.core.security.authc.apikey.CustomApiKeyAuthenticator; + +/** + * An adapter for {@link CustomApiKeyAuthenticator} that implements the {@link Authenticator} interface, so the custom API key authenticator + * can be plugged into the authenticator chain. Module dependencies prevent us from introducing a direct extension point for + * an {@link Authenticator}. + */ +public class PluggableApiKeyAuthenticator implements Authenticator { + private final CustomApiKeyAuthenticator authenticator; + + public PluggableApiKeyAuthenticator(CustomApiKeyAuthenticator authenticator) { + this.authenticator = authenticator; + } + + @Override + public String name() { + return authenticator.name(); + } + + @Override + public AuthenticationToken extractCredentials(Context context) { + return authenticator.extractCredentials(context.getApiKeyString()); + } + + @Override + public void authenticate(Context context, ActionListener> listener) { + final AuthenticationToken authenticationToken = context.getMostRecentAuthenticationToken(); + authenticator.authenticate(authenticationToken, ActionListener.wrap(response -> { + if (response.isAuthenticated()) { + listener.onResponse(response); + } else if (response.getStatus() == AuthenticationResult.Status.TERMINATE) { + final Exception ex = response.getException(); + if (ex == null) { + listener.onFailure(context.getRequest().authenticationFailed(authenticationToken)); + } else { + listener.onFailure(context.getRequest().exceptionProcessingRequest(ex, authenticationToken)); + } + } else if (response.getStatus() == AuthenticationResult.Status.CONTINUE) { + listener.onResponse(AuthenticationResult.notHandled()); + } + }, ex -> listener.onFailure(context.getRequest().exceptionProcessingRequest(ex, authenticationToken)))); + } +} 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 1b5bbd4de9a44..d0a9ee9d78a43 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 @@ -372,6 +372,7 @@ public void init() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); } @@ -665,6 +666,7 @@ public void testAuthenticateSmartRealmOrderingDisabled() { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); User user = new User("_username", "r1"); @@ -791,7 +793,7 @@ public void testAuthenticateCached() throws Exception { public void testAuthenticateNonExistentRestRequestUserThrowsAuthenticationException() throws Exception { when(firstRealm.token(threadContext)).thenReturn( - new UsernamePasswordToken("idonotexist", new SecureString("passwd".toCharArray())) + new UsernamePasswordToken("idonotexist", new SecureString("passwd".toCharArray())) ); try { authenticateBlocking(restRequest, null); @@ -1049,6 +1051,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); boolean requestIdAlreadyPresent = randomBoolean(); @@ -1100,6 +1103,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); threadContext2.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); @@ -1124,6 +1128,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); service.authenticate("_action", new InternalRequest(), InternalUsers.SYSTEM_USER, ActionListener.wrap(result -> { @@ -1187,6 +1192,7 @@ public void testWrongTokenDoesNotFallbackToAnonymous() { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); @@ -1232,6 +1238,7 @@ public void testWrongApiKeyDoesNotFallbackToAnonymous() { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); doAnswer(invocationOnMock -> { @@ -1297,6 +1304,7 @@ public void testAnonymousUserRest() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); RestRequest request = new FakeRestRequest(); @@ -1334,6 +1342,7 @@ public void testAuthenticateRestRequestDisallowAnonymous() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); RestRequest request = new FakeRestRequest(); @@ -1366,6 +1375,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); InternalRequest message = new InternalRequest(); @@ -1402,6 +1412,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { apiKeyService, serviceAccountService, operatorPrivilegesService, + mock(), MeterRegistry.NOOP ); @@ -2069,23 +2080,23 @@ public void testExpiredToken() throws Exception { when(projectIndex.indexExists()).thenReturn(true); User user = new User("_username", "r1"); final Authentication expected = AuthenticationTestHelper.builder() - .user(user) - .realmRef(new RealmRef("realm", "custom", "node")) - .build(false); + .user(user) + .realmRef(new RealmRef("realm", "custom", "node")) + .build(false); PlainActionFuture tokenFuture = new PlainActionFuture<>(); Tuple newTokenBytes = tokenService.getRandomTokenBytes(randomBoolean()); try (ThreadContext.StoredContext ctx = threadContext.stashContext()) { Authentication originatingAuth = AuthenticationTestHelper.builder() - .user(new User("creator")) - .realmRef(new RealmRef("test", "test", "test")) - .build(false); + .user(new User("creator")) + .realmRef(new RealmRef("test", "test", "test")) + .build(false); tokenService.createOAuth2Tokens( - newTokenBytes.v1(), - newTokenBytes.v2(), - expected, - originatingAuth, - Collections.emptyMap(), - tokenFuture + newTokenBytes.v1(), + newTokenBytes.v2(), + expected, + originatingAuth, + Collections.emptyMap(), + tokenFuture ); } String token = tokenFuture.get().getAccessToken(); @@ -2104,8 +2115,8 @@ public void testExpiredToken() throws Exception { } threadContext.putHeader("Authorization", "Bearer " + token); ElasticsearchSecurityException e = expectThrows( - ElasticsearchSecurityException.class, - () -> authenticateBlocking("_action", transportRequest, null, null) + ElasticsearchSecurityException.class, + () -> authenticateBlocking("_action", transportRequest, null, null) ); if (requestIdAlreadyPresent) { assertThat(expectAuditRequestId(threadContext), is(reqId.get())); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticatorChainTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticatorChainTests.java index 363593b83c9c4..bfd122655768b 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticatorChainTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticatorChainTests.java @@ -67,6 +67,7 @@ public class AuthenticatorChainTests extends ESTestCase { private ServiceAccountAuthenticator serviceAccountAuthenticator; private OAuth2TokenAuthenticator oAuth2TokenAuthenticator; private ApiKeyAuthenticator apiKeyAuthenticator; + private PluggableApiKeyAuthenticator pluggableApiKeyAuthenticator; private RealmsAuthenticator realmsAuthenticator; private Authentication authentication; private User fallbackUser; @@ -91,6 +92,7 @@ public void init() { oAuth2TokenAuthenticator = mock(OAuth2TokenAuthenticator.class); apiKeyAuthenticator = mock(ApiKeyAuthenticator.class); realmsAuthenticator = mock(RealmsAuthenticator.class); + pluggableApiKeyAuthenticator = mock(PluggableApiKeyAuthenticator.class); when(realms.getActiveRealms()).thenReturn(List.of(mock(Realm.class))); when(realms.getUnlicensedRealms()).thenReturn(List.of()); final User user = new User(randomAlphaOfLength(8)); @@ -103,6 +105,7 @@ public void init() { authenticationContextSerializer, serviceAccountAuthenticator, oAuth2TokenAuthenticator, + pluggableApiKeyAuthenticator, apiKeyAuthenticator, realmsAuthenticator ); @@ -217,6 +220,13 @@ public void testAuthenticateWithApiKey() throws IOException { new ApiKeyCredentials(randomAlphaOfLength(20), apiKeySecret, randomFrom(ApiKey.Type.values())) ); doCallRealMethod().when(serviceAccountAuthenticator).authenticate(eq(context), anyActionListener()); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener> listener = (ActionListener< + AuthenticationResult>) invocationOnMock.getArguments()[1]; + listener.onResponse(AuthenticationResult.notHandled()); + return null; + }).when(pluggableApiKeyAuthenticator).authenticate(eq(context), any()); doCallRealMethod().when(oAuth2TokenAuthenticator).authenticate(eq(context), anyActionListener()); } doAnswer(invocationOnMock -> { @@ -259,6 +269,13 @@ public void testAuthenticateWithRealms() throws IOException { doCallRealMethod().when(serviceAccountAuthenticator).authenticate(eq(context), anyActionListener()); doCallRealMethod().when(oAuth2TokenAuthenticator).authenticate(eq(context), anyActionListener()); doCallRealMethod().when(apiKeyAuthenticator).authenticate(eq(context), anyActionListener()); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener> listener = (ActionListener< + AuthenticationResult>) invocationOnMock.getArguments()[1]; + listener.onResponse(AuthenticationResult.notHandled()); + return null; + }).when(pluggableApiKeyAuthenticator).authenticate(eq(context), any()); } doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") @@ -321,6 +338,14 @@ public void testContextWithDirectWrongTokenFailsAuthn() { doCallRealMethod().when(serviceAccountAuthenticator).authenticate(eq(context), anyActionListener()); doCallRealMethod().when(oAuth2TokenAuthenticator).authenticate(eq(context), anyActionListener()); doCallRealMethod().when(apiKeyAuthenticator).authenticate(eq(context), anyActionListener()); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener> listener = (ActionListener< + AuthenticationResult>) invocationOnMock.getArguments()[1]; + listener.onResponse(AuthenticationResult.notHandled()); + return null; + }).when(pluggableApiKeyAuthenticator).authenticate(eq(context), any()); + // 1. realms do not consume the token doAnswer(invocationOnMock -> { @SuppressWarnings("unchecked") 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 f26cd59f7532c..7e9c4b861cbc9 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 @@ -160,6 +160,7 @@ public void setupMocks() throws Exception { apiKeyService, serviceAccountService, OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE, + mock(), MeterRegistry.NOOP ); authenticator = new SecondaryAuthenticator(securityContext, authenticationService, auditTrail);