Skip to content

Commit 03dee5b

Browse files
authored
Service Accounts - cache clearing API (#71605)
This PR adds a new Rest endpoint to clear caches used by service account authentication.
1 parent ee3510b commit 03dee5b

File tree

11 files changed

+450
-11
lines changed

11 files changed

+450
-11
lines changed

x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,17 @@ public void testGetServiceAccountTokens() throws IOException {
332332
assertThat(responseAsMap(deleteTokenResponse2).get("found"), is(false));
333333
}
334334

335+
public void testClearCache() throws IOException {
336+
final Request clearCacheRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/"
337+
+ randomFrom("", "*", "api-token-1", "api-token-1,api-token2") + "/_clear_cache");
338+
final Response clearCacheResponse = client().performRequest(clearCacheRequest);
339+
assertOK(clearCacheResponse);
340+
final Map<String, Object> clearCacheResponseMap = responseAsMap(clearCacheResponse);
341+
@SuppressWarnings("unchecked")
342+
final Map<String, Object> nodesMap = (Map<String, Object>) clearCacheResponseMap.get("_nodes");
343+
assertThat(nodesMap.get("failed"), equalTo(0));
344+
}
345+
335346
public void testManageOwnApiKey() throws IOException {
336347
final String token;
337348
if (randomBoolean()) {

x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@
88
package org.elasticsearch.xpack.security.authc.service;
99

1010
import org.elasticsearch.Version;
11+
import org.elasticsearch.action.support.PlainActionFuture;
1112
import org.elasticsearch.client.Client;
1213
import org.elasticsearch.common.Strings;
1314
import org.elasticsearch.common.cache.Cache;
15+
import org.elasticsearch.common.settings.SecureString;
1416
import org.elasticsearch.common.settings.Settings;
1517
import org.elasticsearch.common.util.concurrent.ListenableFuture;
1618
import org.elasticsearch.node.Node;
1719
import org.elasticsearch.test.SecuritySingleNodeTestCase;
20+
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
21+
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest;
22+
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheResponse;
1823
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction;
1924
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest;
2025
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse;
@@ -99,6 +104,40 @@ public void testApiServiceAccountToken() {
99104
assertThat(cache.count(), equalTo(0));
100105
}
101106

107+
public void testClearCache() {
108+
final IndexServiceAccountsTokenStore indexStore = node().injector().getInstance(IndexServiceAccountsTokenStore.class);
109+
final Cache<String, ListenableFuture<CachingServiceAccountsTokenStore.CachedResult>> cache = indexStore.getCache();
110+
final SecureString secret1 = createApiServiceToken("api-token-1");
111+
final SecureString secret2 = createApiServiceToken("api-token-2");
112+
assertThat(cache.count(), equalTo(0));
113+
114+
authenticateWithApiToken("api-token-1", secret1);
115+
assertThat(cache.count(), equalTo(1));
116+
authenticateWithApiToken("api-token-2", secret2);
117+
assertThat(cache.count(), equalTo(2));
118+
119+
final ClearSecurityCacheRequest clearSecurityCacheRequest1 = new ClearSecurityCacheRequest().cacheName("service");
120+
if (randomBoolean()) {
121+
clearSecurityCacheRequest1.keys("elastic/fleet-server/");
122+
}
123+
final PlainActionFuture<ClearSecurityCacheResponse> future1 = new PlainActionFuture<>();
124+
client().execute(ClearSecurityCacheAction.INSTANCE, clearSecurityCacheRequest1, future1);
125+
assertThat(future1.actionGet().failures().isEmpty(), is(true));
126+
assertThat(cache.count(), equalTo(0));
127+
128+
authenticateWithApiToken("api-token-1", secret1);
129+
assertThat(cache.count(), equalTo(1));
130+
authenticateWithApiToken("api-token-2", secret2);
131+
assertThat(cache.count(), equalTo(2));
132+
133+
final ClearSecurityCacheRequest clearSecurityCacheRequest2
134+
= new ClearSecurityCacheRequest().cacheName("service").keys("elastic/fleet-server/api-token-" + randomFrom("1", "2"));
135+
final PlainActionFuture<ClearSecurityCacheResponse> future2 = new PlainActionFuture<>();
136+
client().execute(ClearSecurityCacheAction.INSTANCE, clearSecurityCacheRequest2, future2);
137+
assertThat(future2.actionGet().failures().isEmpty(), is(true));
138+
assertThat(cache.count(), equalTo(1));
139+
}
140+
102141
private Client createServiceAccountClient() {
103142
return createServiceAccountClient(BEARER_TOKEN);
104143
}
@@ -116,4 +155,21 @@ private Authentication getExpectedAuthentication(String tokenName) {
116155
null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, Map.of("_token_name", tokenName)
117156
);
118157
}
158+
159+
private SecureString createApiServiceToken(String tokenName) {
160+
final CreateServiceAccountTokenRequest createServiceAccountTokenRequest =
161+
new CreateServiceAccountTokenRequest("elastic", "fleet-server", tokenName);
162+
final CreateServiceAccountTokenResponse createServiceAccountTokenResponse =
163+
client().execute(CreateServiceAccountTokenAction.INSTANCE, createServiceAccountTokenRequest).actionGet();
164+
assertThat(createServiceAccountTokenResponse.getName(), equalTo(tokenName));
165+
return createServiceAccountTokenResponse.getValue();
166+
}
167+
168+
private void authenticateWithApiToken(String tokenName, SecureString secret) {
169+
final AuthenticateRequest authenticateRequest = new AuthenticateRequest("elastic/fleet-server");
170+
final AuthenticateResponse authenticateResponse =
171+
createServiceAccountClient(secret.toString())
172+
.execute(AuthenticateAction.INSTANCE, authenticateRequest).actionGet();
173+
assertThat(authenticateResponse.authentication(), equalTo(getExpectedAuthentication(tokenName)));
174+
}
119175
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@
267267
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction;
268268
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction;
269269
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlSpMetadataAction;
270+
import org.elasticsearch.xpack.security.rest.action.service.RestClearServiceAccountTokenStoreCacheAction;
270271
import org.elasticsearch.xpack.security.rest.action.service.RestCreateServiceAccountTokenAction;
271272
import org.elasticsearch.xpack.security.rest.action.service.RestDeleteServiceAccountTokenAction;
272273
import org.elasticsearch.xpack.security.rest.action.service.RestGetServiceAccountAction;
@@ -486,6 +487,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
486487
securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);
487488

488489
final CacheInvalidatorRegistry cacheInvalidatorRegistry = new CacheInvalidatorRegistry();
490+
cacheInvalidatorRegistry.registerAlias("service", Set.of("file_service_account_token", "index_service_account_token"));
489491
components.add(cacheInvalidatorRegistry);
490492
securityIndex.get().addIndexStateListener(cacheInvalidatorRegistry::onSecurityIndexStateChange);
491493

@@ -516,7 +518,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
516518
components.add(indexServiceAccountsTokenStore);
517519

518520
final FileServiceAccountsTokenStore fileServiceAccountsTokenStore =
519-
new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool);
521+
new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool, cacheInvalidatorRegistry);
520522

521523
final ServiceAccountService serviceAccountService = new ServiceAccountService(new CompositeServiceAccountsTokenStore(
522524
List.of(fileServiceAccountsTokenStore, indexServiceAccountsTokenStore), threadPool.getThreadContext()), httpTlsRuntimeCheck);
@@ -583,6 +585,8 @@ auditTrailService, failureHandler, threadPool, anonymousUser, getAuthorizationEn
583585

584586
components.add(new SecurityUsageServices(realms, allRolesStore, nativeRoleMappingStore, ipFilter.get()));
585587

588+
cacheInvalidatorRegistry.validate();
589+
586590
return components;
587591
}
588592

@@ -906,6 +910,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
906910
new RestClearRolesCacheAction(settings, getLicenseState()),
907911
new RestClearPrivilegesCacheAction(settings, getLicenseState()),
908912
new RestClearApiKeyCacheAction(settings, getLicenseState()),
913+
new RestClearServiceAccountTokenStoreCacheAction(settings, getLicenseState()),
909914
new RestGetUsersAction(settings, getLicenseState()),
910915
new RestPutUserAction(settings, getLicenseState()),
911916
new RestDeleteUserAction(settings, getLicenseState()),

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.elasticsearch.common.util.concurrent.ListenableFuture;
2020
import org.elasticsearch.threadpool.ThreadPool;
2121
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
22+
import org.elasticsearch.xpack.core.security.support.CacheIteratorHelper;
2223
import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
2324

2425
import java.util.Collection;
@@ -40,6 +41,7 @@ public abstract class CachingServiceAccountsTokenStore implements ServiceAccount
4041
private final Settings settings;
4142
private final ThreadPool threadPool;
4243
private final Cache<String, ListenableFuture<CachedResult>> cache;
44+
private CacheIteratorHelper<String, ListenableFuture<CachedResult>> cacheIteratorHelper;
4345
private final Hasher hasher;
4446

4547
CachingServiceAccountsTokenStore(Settings settings, ThreadPool threadPool) {
@@ -51,8 +53,10 @@ public abstract class CachingServiceAccountsTokenStore implements ServiceAccount
5153
.setExpireAfterWrite(ttl)
5254
.setMaximumWeight(CACHE_MAX_TOKENS_SETTING.get(settings))
5355
.build();
56+
cacheIteratorHelper = new CacheIteratorHelper<>(cache);
5457
} else {
5558
cache = null;
59+
cacheIteratorHelper = null;
5660
}
5761
hasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings));
5862
}
@@ -92,7 +96,12 @@ private void authenticateWithCache(ServiceAccountToken token, ActionListener<Boo
9296
}, listener::onFailure), threadPool.generic(), threadPool.getThreadContext());
9397
} else {
9498
doAuthenticate(token, ActionListener.wrap(success -> {
95-
logger.trace("cache service token [{}] authentication result", token.getQualifiedName());
99+
if (false == success) {
100+
// Do not cache failed attempt
101+
cache.invalidate(token.getQualifiedName(), listenableCacheEntry);
102+
} else {
103+
logger.trace("cache service token [{}] authentication result", token.getQualifiedName());
104+
}
96105
listenableCacheEntry.onResponse(new CachedResult(hasher, success, token));
97106
listener.onResponse(success);
98107
}, e -> {
@@ -107,12 +116,25 @@ private void authenticateWithCache(ServiceAccountToken token, ActionListener<Boo
107116
}
108117
}
109118

119+
/**
120+
* Invalidate cache entries with keys matching to the specified qualified token names.
121+
* @param qualifiedTokenNames The list of qualified toke names. If a name has trailing
122+
* slash, it is treated as a prefix wildcard, i.e. all keys
123+
* with this prefix are considered matching.
124+
*/
110125
@Override
111126
public final void invalidate(Collection<String> qualifiedTokenNames) {
112127
if (cache != null) {
113128
logger.trace("invalidating cache for service token [{}]",
114129
Strings.collectionToCommaDelimitedString(qualifiedTokenNames));
115-
qualifiedTokenNames.forEach(cache::invalidate);
130+
for (String qualifiedTokenName : qualifiedTokenNames) {
131+
if (qualifiedTokenName.endsWith("/")) {
132+
// Wildcard case of invalidating all tokens for a service account, e.g. "elastic/fleet-server/"
133+
cacheIteratorHelper.removeKeysIf(key -> key.startsWith(qualifiedTokenName));
134+
} else {
135+
cache.invalidate(qualifiedTokenName);
136+
}
137+
}
116138
}
117139
}
118140

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
2424
import org.elasticsearch.xpack.core.security.support.NoOpLogger;
2525
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;
26+
import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
2627
import org.elasticsearch.xpack.security.support.FileLineParser;
2728
import org.elasticsearch.xpack.security.support.FileReloadListener;
2829
import org.elasticsearch.xpack.security.support.SecurityFiles;
@@ -47,7 +48,8 @@ public class FileServiceAccountsTokenStore extends CachingServiceAccountsTokenSt
4748
private final CopyOnWriteArrayList<Runnable> refreshListeners;
4849
private volatile Map<String, char[]> tokenHashes;
4950

50-
public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool) {
51+
public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool,
52+
CacheInvalidatorRegistry cacheInvalidatorRegistry) {
5153
super(env.settings(), threadPool);
5254
file = resolveFile(env);
5355
FileWatcher watcher = new FileWatcher(file.getParent());
@@ -63,6 +65,7 @@ public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService res
6365
throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e);
6466
}
6567
refreshListeners = new CopyOnWriteArrayList<>(List.of(this::invalidateAll));
68+
cacheInvalidatorRegistry.registerCacheInvalidator("file_service_account_token", this);
6669
}
6770

6871
@Override
@@ -89,6 +92,11 @@ public void addListener(Runnable listener) {
8992
refreshListeners.add(listener);
9093
}
9194

95+
@Override
96+
public boolean shouldClearOnSecurityIndexStateChange() {
97+
return false;
98+
}
99+
92100
private void notifyRefresh() {
93101
refreshListeners.forEach(Runnable::run);
94102
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.rest.action.service;
9+
10+
import org.elasticsearch.client.node.NodeClient;
11+
import org.elasticsearch.common.settings.Settings;
12+
import org.elasticsearch.license.XPackLicenseState;
13+
import org.elasticsearch.rest.RestRequest;
14+
import org.elasticsearch.rest.action.RestActions;
15+
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
16+
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest;
17+
import org.elasticsearch.xpack.core.security.support.Validation;
18+
import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
19+
20+
import java.io.IOException;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Set;
24+
25+
import static org.elasticsearch.rest.RestRequest.Method.POST;
26+
27+
public class RestClearServiceAccountTokenStoreCacheAction extends SecurityBaseRestHandler {
28+
29+
public RestClearServiceAccountTokenStoreCacheAction(Settings settings, XPackLicenseState licenseState) {
30+
super(settings, licenseState);
31+
}
32+
33+
@Override
34+
public List<Route> routes() {
35+
return List.of(new Route(POST, "/_security/service/{namespace}/{service}/credential/token/{name}/_clear_cache"));
36+
}
37+
38+
@Override
39+
public String getName() {
40+
return "xpack_security_clear_service_account_token_store_cache";
41+
}
42+
43+
@Override
44+
protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
45+
final String namespace = request.param("namespace");
46+
final String service = request.param("service");
47+
String[] tokenNames = request.paramAsStringArrayOrEmptyIfAll("name");
48+
49+
ClearSecurityCacheRequest req = new ClearSecurityCacheRequest().cacheName("service");
50+
if (tokenNames.length == 0) {
51+
// This is the wildcard case for tokenNames
52+
req.keys(namespace + "/" + service + "/");
53+
} else {
54+
final Set<String> qualifiedTokenNames = new HashSet<>(tokenNames.length);
55+
for (String name: tokenNames) {
56+
if (false == Validation.isValidServiceAccountTokenName(name)) {
57+
throw new IllegalArgumentException(Validation.INVALID_SERVICE_ACCOUNT_TOKEN_NAME_MESSAGE + " got: [" + name + "]");
58+
}
59+
qualifiedTokenNames.add(namespace + "/" + service + "/" + name);
60+
}
61+
req.keys(qualifiedTokenNames.toArray(String[]::new));
62+
}
63+
return channel -> client.execute(ClearSecurityCacheAction.INSTANCE, req, new RestActions.NodesResponseRestListener<>(channel));
64+
}
65+
}

0 commit comments

Comments
 (0)