Skip to content

Commit

Permalink
Service Accounts - cache clearing API (#71605)
Browse files Browse the repository at this point in the history
This PR adds a new Rest endpoint to clear caches used by service account
authentication.
  • Loading branch information
ywangd authored Apr 20, 2021
1 parent ee3510b commit 03dee5b
Show file tree
Hide file tree
Showing 11 changed files with 450 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,17 @@ public void testGetServiceAccountTokens() throws IOException {
assertThat(responseAsMap(deleteTokenResponse2).get("found"), is(false));
}

public void testClearCache() throws IOException {
final Request clearCacheRequest = new Request("POST", "_security/service/elastic/fleet-server/credential/token/"
+ randomFrom("", "*", "api-token-1", "api-token-1,api-token2") + "/_clear_cache");
final Response clearCacheResponse = client().performRequest(clearCacheRequest);
assertOK(clearCacheResponse);
final Map<String, Object> clearCacheResponseMap = responseAsMap(clearCacheResponse);
@SuppressWarnings("unchecked")
final Map<String, Object> nodesMap = (Map<String, Object>) clearCacheResponseMap.get("_nodes");
assertThat(nodesMap.get("failed"), equalTo(0));
}

public void testManageOwnApiKey() throws IOException {
final String token;
if (randomBoolean()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@
package org.elasticsearch.xpack.security.authc.service;

import org.elasticsearch.Version;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.client.Client;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.cache.Cache;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ListenableFuture;
import org.elasticsearch.node.Node;
import org.elasticsearch.test.SecuritySingleNodeTestCase;
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest;
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheResponse;
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction;
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest;
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse;
Expand Down Expand Up @@ -99,6 +104,40 @@ public void testApiServiceAccountToken() {
assertThat(cache.count(), equalTo(0));
}

public void testClearCache() {
final IndexServiceAccountsTokenStore indexStore = node().injector().getInstance(IndexServiceAccountsTokenStore.class);
final Cache<String, ListenableFuture<CachingServiceAccountsTokenStore.CachedResult>> cache = indexStore.getCache();
final SecureString secret1 = createApiServiceToken("api-token-1");
final SecureString secret2 = createApiServiceToken("api-token-2");
assertThat(cache.count(), equalTo(0));

authenticateWithApiToken("api-token-1", secret1);
assertThat(cache.count(), equalTo(1));
authenticateWithApiToken("api-token-2", secret2);
assertThat(cache.count(), equalTo(2));

final ClearSecurityCacheRequest clearSecurityCacheRequest1 = new ClearSecurityCacheRequest().cacheName("service");
if (randomBoolean()) {
clearSecurityCacheRequest1.keys("elastic/fleet-server/");
}
final PlainActionFuture<ClearSecurityCacheResponse> future1 = new PlainActionFuture<>();
client().execute(ClearSecurityCacheAction.INSTANCE, clearSecurityCacheRequest1, future1);
assertThat(future1.actionGet().failures().isEmpty(), is(true));
assertThat(cache.count(), equalTo(0));

authenticateWithApiToken("api-token-1", secret1);
assertThat(cache.count(), equalTo(1));
authenticateWithApiToken("api-token-2", secret2);
assertThat(cache.count(), equalTo(2));

final ClearSecurityCacheRequest clearSecurityCacheRequest2
= new ClearSecurityCacheRequest().cacheName("service").keys("elastic/fleet-server/api-token-" + randomFrom("1", "2"));
final PlainActionFuture<ClearSecurityCacheResponse> future2 = new PlainActionFuture<>();
client().execute(ClearSecurityCacheAction.INSTANCE, clearSecurityCacheRequest2, future2);
assertThat(future2.actionGet().failures().isEmpty(), is(true));
assertThat(cache.count(), equalTo(1));
}

private Client createServiceAccountClient() {
return createServiceAccountClient(BEARER_TOKEN);
}
Expand All @@ -116,4 +155,21 @@ private Authentication getExpectedAuthentication(String tokenName) {
null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, Map.of("_token_name", tokenName)
);
}

private SecureString createApiServiceToken(String tokenName) {
final CreateServiceAccountTokenRequest createServiceAccountTokenRequest =
new CreateServiceAccountTokenRequest("elastic", "fleet-server", tokenName);
final CreateServiceAccountTokenResponse createServiceAccountTokenResponse =
client().execute(CreateServiceAccountTokenAction.INSTANCE, createServiceAccountTokenRequest).actionGet();
assertThat(createServiceAccountTokenResponse.getName(), equalTo(tokenName));
return createServiceAccountTokenResponse.getValue();
}

private void authenticateWithApiToken(String tokenName, SecureString secret) {
final AuthenticateRequest authenticateRequest = new AuthenticateRequest("elastic/fleet-server");
final AuthenticateResponse authenticateResponse =
createServiceAccountClient(secret.toString())
.execute(AuthenticateAction.INSTANCE, authenticateRequest).actionGet();
assertThat(authenticateResponse.authentication(), equalTo(getExpectedAuthentication(tokenName)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlSpMetadataAction;
import org.elasticsearch.xpack.security.rest.action.service.RestClearServiceAccountTokenStoreCacheAction;
import org.elasticsearch.xpack.security.rest.action.service.RestCreateServiceAccountTokenAction;
import org.elasticsearch.xpack.security.rest.action.service.RestDeleteServiceAccountTokenAction;
import org.elasticsearch.xpack.security.rest.action.service.RestGetServiceAccountAction;
Expand Down Expand Up @@ -486,6 +487,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);

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

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

final FileServiceAccountsTokenStore fileServiceAccountsTokenStore =
new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool);
new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool, cacheInvalidatorRegistry);

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

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

cacheInvalidatorRegistry.validate();

return components;
}

Expand Down Expand Up @@ -906,6 +910,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
new RestClearRolesCacheAction(settings, getLicenseState()),
new RestClearPrivilegesCacheAction(settings, getLicenseState()),
new RestClearApiKeyCacheAction(settings, getLicenseState()),
new RestClearServiceAccountTokenStoreCacheAction(settings, getLicenseState()),
new RestGetUsersAction(settings, getLicenseState()),
new RestPutUserAction(settings, getLicenseState()),
new RestDeleteUserAction(settings, getLicenseState()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.elasticsearch.common.util.concurrent.ListenableFuture;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.core.security.support.CacheIteratorHelper;
import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;

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

CachingServiceAccountsTokenStore(Settings settings, ThreadPool threadPool) {
Expand All @@ -51,8 +53,10 @@ public abstract class CachingServiceAccountsTokenStore implements ServiceAccount
.setExpireAfterWrite(ttl)
.setMaximumWeight(CACHE_MAX_TOKENS_SETTING.get(settings))
.build();
cacheIteratorHelper = new CacheIteratorHelper<>(cache);
} else {
cache = null;
cacheIteratorHelper = null;
}
hasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings));
}
Expand Down Expand Up @@ -92,7 +96,12 @@ private void authenticateWithCache(ServiceAccountToken token, ActionListener<Boo
}, listener::onFailure), threadPool.generic(), threadPool.getThreadContext());
} else {
doAuthenticate(token, ActionListener.wrap(success -> {
logger.trace("cache service token [{}] authentication result", token.getQualifiedName());
if (false == success) {
// Do not cache failed attempt
cache.invalidate(token.getQualifiedName(), listenableCacheEntry);
} else {
logger.trace("cache service token [{}] authentication result", token.getQualifiedName());
}
listenableCacheEntry.onResponse(new CachedResult(hasher, success, token));
listener.onResponse(success);
}, e -> {
Expand All @@ -107,12 +116,25 @@ private void authenticateWithCache(ServiceAccountToken token, ActionListener<Boo
}
}

/**
* Invalidate cache entries with keys matching to the specified qualified token names.
* @param qualifiedTokenNames The list of qualified toke names. If a name has trailing
* slash, it is treated as a prefix wildcard, i.e. all keys
* with this prefix are considered matching.
*/
@Override
public final void invalidate(Collection<String> qualifiedTokenNames) {
if (cache != null) {
logger.trace("invalidating cache for service token [{}]",
Strings.collectionToCommaDelimitedString(qualifiedTokenNames));
qualifiedTokenNames.forEach(cache::invalidate);
for (String qualifiedTokenName : qualifiedTokenNames) {
if (qualifiedTokenName.endsWith("/")) {
// Wildcard case of invalidating all tokens for a service account, e.g. "elastic/fleet-server/"
cacheIteratorHelper.removeKeysIf(key -> key.startsWith(qualifiedTokenName));
} else {
cache.invalidate(qualifiedTokenName);
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
import org.elasticsearch.xpack.core.security.support.NoOpLogger;
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;
import org.elasticsearch.xpack.security.support.SecurityFiles;
Expand All @@ -47,7 +48,8 @@ public class FileServiceAccountsTokenStore extends CachingServiceAccountsTokenSt
private final CopyOnWriteArrayList<Runnable> refreshListeners;
private volatile Map<String, char[]> tokenHashes;

public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool) {
public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool,
CacheInvalidatorRegistry cacheInvalidatorRegistry) {
super(env.settings(), threadPool);
file = resolveFile(env);
FileWatcher watcher = new FileWatcher(file.getParent());
Expand All @@ -63,6 +65,7 @@ public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService res
throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e);
}
refreshListeners = new CopyOnWriteArrayList<>(List.of(this::invalidateAll));
cacheInvalidatorRegistry.registerCacheInvalidator("file_service_account_token", this);
}

@Override
Expand All @@ -89,6 +92,11 @@ public void addListener(Runnable listener) {
refreshListeners.add(listener);
}

@Override
public boolean shouldClearOnSecurityIndexStateChange() {
return false;
}

private void notifyRefresh() {
refreshListeners.forEach(Runnable::run);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.security.rest.action.service;

import org.elasticsearch.client.node.NodeClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.action.RestActions;
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest;
import org.elasticsearch.xpack.core.security.support.Validation;
import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;

import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import static org.elasticsearch.rest.RestRequest.Method.POST;

public class RestClearServiceAccountTokenStoreCacheAction extends SecurityBaseRestHandler {

public RestClearServiceAccountTokenStoreCacheAction(Settings settings, XPackLicenseState licenseState) {
super(settings, licenseState);
}

@Override
public List<Route> routes() {
return List.of(new Route(POST, "/_security/service/{namespace}/{service}/credential/token/{name}/_clear_cache"));
}

@Override
public String getName() {
return "xpack_security_clear_service_account_token_store_cache";
}

@Override
protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
final String namespace = request.param("namespace");
final String service = request.param("service");
String[] tokenNames = request.paramAsStringArrayOrEmptyIfAll("name");

ClearSecurityCacheRequest req = new ClearSecurityCacheRequest().cacheName("service");
if (tokenNames.length == 0) {
// This is the wildcard case for tokenNames
req.keys(namespace + "/" + service + "/");
} else {
final Set<String> qualifiedTokenNames = new HashSet<>(tokenNames.length);
for (String name: tokenNames) {
if (false == Validation.isValidServiceAccountTokenName(name)) {
throw new IllegalArgumentException(Validation.INVALID_SERVICE_ACCOUNT_TOKEN_NAME_MESSAGE + " got: [" + name + "]");
}
qualifiedTokenNames.add(namespace + "/" + service + "/" + name);
}
req.keys(qualifiedTokenNames.toArray(String[]::new));
}
return channel -> client.execute(ClearSecurityCacheAction.INSTANCE, req, new RestActions.NodesResponseRestListener<>(channel));
}
}
Loading

0 comments on commit 03dee5b

Please sign in to comment.