Skip to content

Add Support for Providing a custom ServiceAccountTokenStore through SecurityExtensions #126612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.service.ServiceAccountTokenStore;
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
Expand Down Expand Up @@ -114,6 +115,17 @@ default List<BiConsumer<Set<String>, ActionListener<RoleRetrievalResult>>> getRo
return Collections.emptyList();
}

/**
* Returns a service account token store to authenticate service account tokens, or null to use the default service account token stores
*
* If a {@link ServiceAccountTokenStore} is provided here it will replace existing service account token stores
*
* @param components Access to components that may be used to authenticate service account tokens
*/
default ServiceAccountTokenStore getServiceAccountTokenStore(SecurityComponents components) {
return null;
}

/**
* Returns a authorization engine for authorizing requests, or null to use the default authorization mechanism.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,6 @@ public int compareTo(TokenInfo o) {

public enum TokenSource {
INDEX,
FILE;
FILE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These had to move since they're now part of the SecurityExtension interface through ServiceAccountTokenStore.

*/

package org.elasticsearch.xpack.security.authc.service;
package org.elasticsearch.xpack.core.security.authc.service;

import org.apache.logging.log4j.util.Strings;
import org.elasticsearch.common.io.stream.StreamInput;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;
package org.elasticsearch.xpack.core.security.authc.service;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
Expand All @@ -14,9 +14,9 @@
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.core.CharArrays;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.security.support.Validation;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
Expand Down Expand Up @@ -51,7 +51,6 @@ public class ServiceAccountToken implements AuthenticationToken, Closeable {
private final ServiceAccountTokenId tokenId;
private final SecureString secret;

// pkg private for testing
ServiceAccountToken(ServiceAccountId accountId, String tokenName, SecureString secret) {
tokenId = new ServiceAccountTokenId(accountId, tokenName);
this.secret = Objects.requireNonNull(secret, "service account token secret cannot be null");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;
package org.elasticsearch.xpack.core.security.authc.service;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.xpack.core.security.action.service.TokenInfo.TokenSource;
Expand All @@ -24,11 +24,23 @@ class StoreAuthenticationResult {
private final boolean success;
private final TokenSource tokenSource;

public StoreAuthenticationResult(boolean success, TokenSource tokenSource) {
private StoreAuthenticationResult(TokenSource tokenSource, boolean success) {
this.success = success;
this.tokenSource = tokenSource;
}

public static StoreAuthenticationResult successful(TokenSource tokenSource) {
return new StoreAuthenticationResult(tokenSource, true);
}

public static StoreAuthenticationResult failed(TokenSource tokenSource) {
return new StoreAuthenticationResult(tokenSource, false);
}

public static StoreAuthenticationResult fromBooleanResult(TokenSource tokenSource, boolean result) {
return result ? successful(tokenSource) : failed(tokenSource);
}

public boolean isSuccess() {
return success;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;
package org.elasticsearch.xpack.core.security.authc.service;

import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.core.security.support.Validation;
import org.elasticsearch.xpack.core.security.support.ValidationTests;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;

import java.io.IOException;

Expand Down
1 change: 1 addition & 0 deletions x-pack/plugin/security/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
exports org.elasticsearch.xpack.security.rest.action.apikey to org.elasticsearch.internal.security;
exports org.elasticsearch.xpack.security.support to org.elasticsearch.internal.security;
exports org.elasticsearch.xpack.security.authz.store to org.elasticsearch.internal.security;
exports org.elasticsearch.xpack.security.authc.service;

provides org.elasticsearch.index.SlowLogFieldProvider with org.elasticsearch.xpack.security.slowlog.SecuritySlowLogFieldProvider;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,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.service.ServiceAccountTokenStore;
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine;
Expand Down Expand Up @@ -310,6 +311,7 @@
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.authc.jwt.JwtRealm;
import org.elasticsearch.xpack.security.authc.service.CachingServiceAccountTokenStore;
import org.elasticsearch.xpack.security.authc.service.CompositeServiceAccountTokenStore;
import org.elasticsearch.xpack.security.authc.service.FileServiceAccountTokenStore;
import org.elasticsearch.xpack.security.authc.service.IndexServiceAccountTokenStore;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
Expand Down Expand Up @@ -915,12 +917,54 @@ Collection<Object> createComponents(
this.realms.set(realms);

systemIndices.getMainIndexManager().addStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);

final CacheInvalidatorRegistry cacheInvalidatorRegistry = new CacheInvalidatorRegistry();
cacheInvalidatorRegistry.registerAlias("service", Set.of("file_service_account_token", "index_service_account_token"));
components.add(cacheInvalidatorRegistry);
systemIndices.getMainIndexManager().addStateListener(cacheInvalidatorRegistry::onSecurityIndexStateChange);

final IndexServiceAccountTokenStore indexServiceAccountTokenStore = new IndexServiceAccountTokenStore(
settings,
threadPool,
getClock(),
client,
systemIndices.getMainIndexManager(),
clusterService,
cacheInvalidatorRegistry
);
components.add(indexServiceAccountTokenStore);

final FileServiceAccountTokenStore fileServiceAccountTokenStore = new FileServiceAccountTokenStore(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FileServiceAccountTokenStore is required by guice injection into TransportGetServiceAccountNodesCredentialsAction so have to create it even if it's not used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's techinically possible to not create TransportGetServiceAccountNodesCredentialsAction (and other similar actions) as well if the extension store is found. It might be worth the effort since it is strange to have the API available when the underlying mechanism is effectively disabled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the line of reasoning, except I think it would be better for the action to exist and fail rather that to have a generic 404 in the Rest API.

Doing that right sounds like a multi-line / multi-file change, so perhaps we can revisit it in a future PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransportGetServiceAccountNodesCredentialsAction used to rely on a FileServiceAccountTokenStore implementation being provided as a component. I've now changed it to be a ReadOnlyServiceAccountTokenStore. The security extension provides an instance of ReadOnlyServiceAccountTokenStore that will fail any attempts to do local node token lookup, hence the API will fail. This allows us to still create TransportGetServiceAccountNodesCredentialsAction but fail the action if called in serverless.

It's techinically possible to not create TransportGetServiceAccountNodesCredentialsAction (and other similar actions) as well if the extension store is found. It might be worth the effort since it is strange to have the API available when the underlying mechanism is effectively disabled.

I don't see any other places where transport actions are conditionally excluded in the security plugin, so that would be a new pattern I think? I think I prefer having the actions there, but the backing store preventing them from being used. Let me know if you prefer the 404 approach @ywangd .

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transport actions are conditionally excluded in the security plugin

We do exclude most actions if security is disabled? That said, I don't have a strong opinion on whether it should be 404. I also agree that we can tackle this separately.

environment,
resourceWatcherService,
threadPool,
clusterService,
cacheInvalidatorRegistry
);
components.add(fileServiceAccountTokenStore);
cacheInvalidatorRegistry.registerAlias("service", Set.of("file_service_account_token", "index_service_account_token"));

List<ServiceAccountTokenStore> extensionTokenStores = securityExtensions.stream()
.map(extension -> extension.getServiceAccountTokenStore(extensionComponents))
.filter(Objects::nonNull)
.toList();

ServiceAccountService serviceAccountService;

if (extensionTokenStores.isEmpty()) {
serviceAccountService = new ServiceAccountService(client, fileServiceAccountTokenStore, indexServiceAccountTokenStore);
} else {
// Completely handover service account token management to the extension if provided, this will disable the index managed
// service account tokens managed through the service account token API
logger.debug("Service account authentication handled by extension, disabling file and index token stores");
components.addAll(extensionTokenStores);
serviceAccountService = new ServiceAccountService(
client,
new CompositeServiceAccountTokenStore(extensionTokenStores, client.threadPool().getThreadContext())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer we ensure there is only a single extensionTokenStore and avoid the composite wrapping.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be my inclination as well. We have no reason to support multiple stores, and we don't really know whether 2 arbitrary implementations would co-exist correctly.

);
// TODO Should this also register with the cacheInvalidatorRegistry?
}

components.add(serviceAccountService);

systemIndices.getMainIndexManager().addStateListener(cacheInvalidatorRegistry::onSecurityIndexStateChange);
final NativePrivilegeStore privilegeStore = new NativePrivilegeStore(
settings,
client,
Expand Down Expand Up @@ -1004,33 +1048,6 @@ Collection<Object> createComponents(
);
components.add(apiKeyService);

final IndexServiceAccountTokenStore indexServiceAccountTokenStore = new IndexServiceAccountTokenStore(
settings,
threadPool,
getClock(),
client,
systemIndices.getMainIndexManager(),
clusterService,
cacheInvalidatorRegistry
);
components.add(indexServiceAccountTokenStore);

final FileServiceAccountTokenStore fileServiceAccountTokenStore = new FileServiceAccountTokenStore(
environment,
resourceWatcherService,
threadPool,
clusterService,
cacheInvalidatorRegistry
);
components.add(fileServiceAccountTokenStore);

final ServiceAccountService serviceAccountService = new ServiceAccountService(
client,
fileServiceAccountTokenStore,
indexServiceAccountTokenStore
);
components.add(serviceAccountService);

final RoleProviders roleProviders = new RoleProviders(
reservedRolesStore,
fileRolesStore.get(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountRequest;
import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountResponse;
import org.elasticsearch.xpack.core.security.action.service.ServiceAccountInfo;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;

import java.util.function.Predicate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountCredentialsNodesResponse;
import org.elasticsearch.xpack.core.security.action.service.GetServiceAccountNodesCredentialsAction;
import org.elasticsearch.xpack.core.security.action.service.TokenInfo;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.security.authc.service.FileServiceAccountTokenStore;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;

import java.io.IOException;
import java.util.List;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import org.elasticsearch.xpack.core.security.authc.AuthenticationField;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
Expand All @@ -110,7 +111,6 @@
import org.elasticsearch.xpack.security.audit.AuditTrail;
import org.elasticsearch.xpack.security.audit.AuditUtil;
import org.elasticsearch.xpack.security.authc.ApiKeyService;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.security.rest.RemoteHostHeader;
import org.elasticsearch.xpack.security.transport.filter.IPFilter;
import org.elasticsearch.xpack.security.transport.filter.SecurityIpFilterRule;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
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.service.ServiceAccountToken;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.security.metric.InstrumentedSecurityActionListener;
import org.elasticsearch.xpack.security.metric.SecurityMetricType;
import org.elasticsearch.xpack.security.metric.SecurityMetrics;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import org.elasticsearch.core.TimeValue;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.xpack.core.security.action.service.TokenInfo.TokenSource;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore;
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;

Expand Down Expand Up @@ -97,10 +99,10 @@ private void authenticateWithCache(ServiceAccountToken token, ActionListener<Sto
if (valueAlreadyInCache.get()) {
listenableCacheEntry.addListener(listener.delegateFailureAndWrap((l, result) -> {
if (result.success) {
l.onResponse(new StoreAuthenticationResult(result.verify(token), getTokenSource()));
l.onResponse(StoreAuthenticationResult.fromBooleanResult(getTokenSource(), result.verify(token)));
} else if (result.verify(token)) {
// same wrong token
l.onResponse(new StoreAuthenticationResult(false, getTokenSource()));
l.onResponse(StoreAuthenticationResult.failed(getTokenSource()));
} else {
cache.invalidate(token.getQualifiedName(), listenableCacheEntry);
authenticateWithCache(token, l);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.xpack.core.common.IteratingActionListener;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore;

import java.util.List;
import java.util.function.Function;
Expand All @@ -38,7 +40,7 @@ public void authenticate(ServiceAccountToken token, ActionListener<StoreAuthenti
stores,
threadContext,
Function.identity(),
storeAuthenticationResult -> false == storeAuthenticationResult.isSuccess()
storeAuthenticationResult -> storeAuthenticationResult.isSuccess() == false
);
try {
authenticatingListener.run();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package org.elasticsearch.xpack.security.authc.service;

import org.elasticsearch.common.Strings;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
import org.elasticsearch.xpack.core.security.user.User;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
import org.elasticsearch.xpack.core.XPackPlugin;
import org.elasticsearch.xpack.core.security.action.service.TokenInfo;
import org.elasticsearch.xpack.core.security.action.service.TokenInfo.TokenSource;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.core.security.support.NoOpLogger;
import org.elasticsearch.xpack.security.PrivilegedFileWatcher;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
import org.elasticsearch.xpack.security.support.FileLineParser;
import org.elasticsearch.xpack.security.support.FileReloadListener;
Expand All @@ -50,6 +51,7 @@ public class FileServiceAccountTokenStore extends CachingServiceAccountTokenStor
private final CopyOnWriteArrayList<Runnable> refreshListeners;
private volatile Map<String, char[]> tokenHashes;

@SuppressWarnings("this-escape")
public FileServiceAccountTokenStore(
Environment env,
ResourceWatcherService resourceWatcherService,
Expand Down Expand Up @@ -82,8 +84,8 @@ public void doAuthenticate(ServiceAccountToken token, ActionListener<StoreAuthen
// because it is not expected to have a large number of service tokens.
listener.onResponse(
Optional.ofNullable(tokenHashes.get(token.getQualifiedName()))
.map(hash -> new StoreAuthenticationResult(Hasher.verifyHash(token.getSecret(), hash), getTokenSource()))
.orElse(new StoreAuthenticationResult(false, getTokenSource()))
.map(hash -> StoreAuthenticationResult.fromBooleanResult(getTokenSource(), Hasher.verifyHash(token.getSecret(), hash)))
.orElse(StoreAuthenticationResult.failed(getTokenSource()))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
import org.elasticsearch.core.Predicates;
import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken;
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountToken.ServiceAccountTokenId;
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.core.security.support.Validation;
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountToken.ServiceAccountTokenId;
import org.elasticsearch.xpack.security.support.FileAttributesChecker;

import java.nio.file.Path;
Expand Down
Loading