From bc8aacf42eb1ee1d85b8bfa46c798430d24f7291 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 6 Jan 2025 12:42:34 -0500 Subject: [PATCH] Use transport action Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 12 +- .../action/apitokens/ApiTokenAction.java | 87 +++++++--- .../apitokens/ApiTokenIndexListenerCache.java | 162 +++++++++++------- .../apitokens/ApiTokenUpdateAction.java | 24 +++ .../apitokens/ApiTokenUpdateNodeResponse.java | 28 +++ .../apitokens/ApiTokenUpdateRequest.java | 35 ++++ .../apitokens/ApiTokenUpdateResponse.java | 60 +++++++ .../TransportApiTokenUpdateAction.java | 104 +++++++++++ .../security/http/ApiTokenAuthenticator.java | 2 +- .../security/privileges/ActionPrivileges.java | 9 +- .../apitokens/ApiTokenAuthenticatorTest.java | 24 +-- 11 files changed, 427 insertions(+), 120 deletions(-) create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index efe51d2e74..048fa1fea9 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -133,6 +133,8 @@ import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.apitokens.ApiTokenAction; import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; +import org.opensearch.security.action.apitokens.ApiTokenUpdateAction; +import org.opensearch.security.action.apitokens.TransportApiTokenUpdateAction; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -686,6 +688,7 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre List> actions = new ArrayList<>(1); if (!disabled && !SSLConfig.isSslOnlyMode()) { actions.add(new ActionHandler<>(ConfigUpdateAction.INSTANCE, TransportConfigUpdateAction.class)); + actions.add(new ActionHandler<>(ApiTokenUpdateAction.INSTANCE, TransportApiTokenUpdateAction.class)); // external storage does not support reload and does not provide SSL certs info if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); @@ -719,14 +722,6 @@ public void onIndexModule(IndexModule indexModule) { ) ); - // TODO: Is there a higher level approach that makes more sense here? Does this cover unsuccessful index ops? - if (ConfigConstants.OPENSEARCH_API_TOKENS_INDEX.equals(indexModule.getIndex().getName())) { - ApiTokenIndexListenerCache apiTokenIndexListenerCacher = ApiTokenIndexListenerCache.getInstance(); - apiTokenIndexListenerCacher.initialize(); - indexModule.addIndexOperationListener(apiTokenIndexListenerCacher); - log.warn("Security plugin started listening to operations on index {}", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - } - indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1105,6 +1100,7 @@ public Collection createComponents( adminDns = new AdminDNs(settings); cr = ConfigurationRepository.create(settings, this.configPath, threadPool, localClient, clusterService, auditLog); + ApiTokenIndexListenerCache.getInstance().initialize(clusterService, localClient); this.passwordHasher = PasswordHasherFactory.createPasswordHasher(settings); diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index e2e373812f..75bf3ffa01 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -20,14 +20,18 @@ import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.client.Client; import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.rest.BaseRestHandler; import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; import org.opensearch.security.identity.SecurityTokenManager; @@ -48,6 +52,7 @@ public class ApiTokenAction extends BaseRestHandler { private final ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); private static final List ROUTES = addRoutesPrefix( ImmutableList.of( @@ -133,20 +138,32 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) ); - builder.startObject(); - builder.field("Api Token: ", token); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + // Then trigger the update action + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("Api Token: ", token); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (IOException e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token creation"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token creation"); + } + }); } catch (final Exception exception) { - builder.startObject() - .field("error", "An unexpected error occurred. Please check the input and try again.") - .field("message", exception.getMessage()) - .endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } @@ -239,22 +256,46 @@ private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) validateRequestParameters(requestBody); apiTokenRepository.deleteApiToken((String) requestBody.get(NAME_FIELD)); - builder.startObject(); - builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token update"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token deletion"); + } + }); } catch (final ApiTokenException exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.NOT_FOUND, builder); + sendErrorResponse(channel, RestStatus.NOT_FOUND, exception.getMessage()); } catch (final Exception exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + BytesRestResponse response = new BytesRestResponse(status, builder); + channel.sendResponse(response); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java index 8b87f2fa03..a27c1e06db 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -8,105 +8,137 @@ package org.opensearch.security.action.apitokens; -import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.common.xcontent.LoggingDeprecationHandler; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.common.bytes.BytesReference; -import org.opensearch.core.index.shard.ShardId; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.index.engine.Engine; -import org.opensearch.index.shard.IndexingOperationListener; - -/** - * This class implements an index operation listener for operations performed on api tokens - * These indices are defined on bootstrap and configured to listen in OpenSearchSecurityPlugin.java - */ -public class ApiTokenIndexListenerCache implements IndexingOperationListener { +import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterChangedEvent; +import org.opensearch.cluster.ClusterStateListener; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.security.support.ConfigConstants; - private final static Logger log = LogManager.getLogger(ApiTokenIndexListenerCache.class); +public class ApiTokenIndexListenerCache implements ClusterStateListener { + private static final Logger log = LogManager.getLogger(ApiTokenIndexListenerCache.class); private static final ApiTokenIndexListenerCache INSTANCE = new ApiTokenIndexListenerCache(); - private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); - private Map jtis = new ConcurrentHashMap<>(); + private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); + private final Map jtis = new ConcurrentHashMap<>(); - private boolean initialized; + private final AtomicBoolean initialized = new AtomicBoolean(false); + private ClusterService clusterService; + private Client client; private ApiTokenIndexListenerCache() {} public static ApiTokenIndexListenerCache getInstance() { - return ApiTokenIndexListenerCache.INSTANCE; + return INSTANCE; + } + + public void initialize(ClusterService clusterService, Client client) { + if (initialized.compareAndSet(false, true)) { + this.clusterService = clusterService; + this.client = client; + + // Register as cluster state listener + this.clusterService.addListener(this); + } } - /** - * Initializes the ApiTokenIndexListenerCache. - * This method is called during the plugin's initialization process. - * - */ - public void initialize() { + @Override + public void clusterChanged(ClusterChangedEvent event) { + // Reload cache if the security index has changed + IndexMetadata securityIndex = event.state().metadata().index(getSecurityIndexName()); + if (securityIndex != null) { + reloadApiTokensFromIndex(); + } + } - if (initialized) { + void reloadApiTokensFromIndex() { + if (!initialized.get()) { + log.debug("Cache not yet initialized or client is null, skipping reload"); return; } - initialized = true; + if (clusterService.state() != null && clusterService.state().blocks().hasGlobalBlockWithStatus(RestStatus.SERVICE_UNAVAILABLE)) { + log.debug("Cluster not yet ready, skipping API tokens cache reload"); + return; + } + try { + // Clear existing caches + log.info("Reloading API tokens cache from index: {}", jtis.entrySet().toString()); + + idToJtiMap.clear(); + jtis.clear(); + + // Search request to get all API tokens from the security index + client.prepareSearch(getSecurityIndexName()) + .setQuery(QueryBuilders.matchAllQuery()) + .execute() + .actionGet() + .getHits() + .forEach(hit -> { + // Parse the document and update the cache + Map source = hit.getSourceAsMap(); + String id = hit.getId(); + String jti = (String) source.get("jti"); + Permissions permissions = parsePermissions(source); + + idToJtiMap.put(id, jti); + jtis.put(jti, permissions); + }); + + log.debug("Successfully reloaded API tokens cache"); + } catch (Exception e) { + log.error("Failed to reload API tokens cache", e); + } } - public boolean isInitialized() { - return initialized; + private String getSecurityIndexName() { + // Return the name of your security index + return ConfigConstants.OPENSEARCH_API_TOKENS_INDEX; } - /** - * This method is called after an index operation is performed. - * It adds the JTI of the indexed document to the cache and maps the document ID to the JTI (for deletion handling). - * @param shardId The shard ID of the index where the operation was performed. - * @param index The index where the operation was performed. - * @param result The result of the index operation. - */ - @Override - public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult result) { - BytesReference sourceRef = index.source(); - - try { - XContentParser parser = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, sourceRef.streamInput()); + @SuppressWarnings("unchecked") + private Permissions parsePermissions(Map source) { + // Implement parsing logic for permissions from the document + return new Permissions( + (List) source.get(ApiToken.CLUSTER_PERMISSIONS_FIELD), + (List) source.get(ApiToken.INDEX_PERMISSIONS_FIELD) + ); + } - ApiToken token = ApiToken.fromXContent(parser); - jtis.put(token.getJti(), new Permissions(token.getClusterPermissions(), token.getIndexPermissions())); - idToJtiMap.put(index.id(), token.getJti()); + // Getter methods for cached data + public String getJtiForId(String id) { + return idToJtiMap.get(id); + } - } catch (IOException e) { - log.error("Failed to parse indexed document", e); - } + public Permissions getPermissionsForJti(String jti) { + return jtis.get(jti); } - /** - * This method is called after a delete operation is performed. - * It deletes the corresponding document id in the map and the corresponding jti from the cache. - * @param shardId The shard ID of the index where the delete operation was performed. - * @param delete The delete operation that was performed. - * @param result The result of the delete operation. - */ - @Override - public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResult result) { - String docId = delete.id(); - String jti = idToJtiMap.remove(docId); - if (jti != null) { - jtis.remove(jti); - log.debug("Removed token with ID {} and JTI {} from cache", docId, jti); - } + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); } public Map getJtis() { return jtis; } + // Cleanup method + public void close() { + if (clusterService != null) { + clusterService.removeListener(this); + } + } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java new file mode 100644 index 0000000000..c9d324c52f --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import org.opensearch.action.ActionType; + +public class ApiTokenUpdateAction extends ActionType { + + public static final ApiTokenUpdateAction INSTANCE = new ApiTokenUpdateAction(); + public static final String NAME = "cluster:admin/opendistro_security/apitoken/update"; + + protected ApiTokenUpdateAction() { + super(NAME, ApiTokenUpdateResponse::new); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java new file mode 100644 index 0000000000..429310d966 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; + +public class ApiTokenUpdateNodeResponse extends BaseNodeResponse { + public ApiTokenUpdateNodeResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateNodeResponse(DiscoveryNode node) { + super(node); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java new file mode 100644 index 0000000000..f78c0370d5 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ApiTokenUpdateRequest extends BaseNodesRequest { + + public ApiTokenUpdateRequest(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateRequest() throws IOException { + super(new String[0]); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java new file mode 100644 index 0000000000..99d94bd578 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenUpdateResponse extends BaseNodesResponse implements ToXContentObject { + + public ApiTokenUpdateResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateResponse( + final ClusterName clusterName, + List nodes, + List failures + ) { + super(clusterName, nodes, failures); + } + + @Override + public List readNodesFrom(final StreamInput in) throws IOException { + return in.readList(ApiTokenUpdateNodeResponse::new); + } + + @Override + public void writeNodesTo(final StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("ApiTokenupdate_response"); + builder.field("nodes", getNodesMap()); + builder.field("node_size", getNodes().size()); + builder.field("has_failures", hasFailures()); + builder.field("failures_size", failures().size()); + builder.endObject(); + + return builder; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java new file mode 100644 index 0000000000..f47bdfad81 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenIndexListenerCache apiTokenCache; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenCache = ApiTokenIndexListenerCache.getInstance(); + this.clusterService = clusterService; + } + + public static class NodeApiTokenUpdateRequest extends TransportRequest { + ApiTokenUpdateRequest request; + + public NodeApiTokenUpdateRequest(ApiTokenUpdateRequest request) { + this.request = request; + } + + public NodeApiTokenUpdateRequest(StreamInput streamInput) throws IOException { + super(streamInput); + this.request = new ApiTokenUpdateRequest(streamInput); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + } + + @Override + protected ApiTokenUpdateNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ApiTokenUpdateNodeResponse(in); + } + + @Override + protected ApiTokenUpdateResponse newResponse( + ApiTokenUpdateRequest request, + List responses, + List failures + ) { + return new ApiTokenUpdateResponse(this.clusterService.getClusterName(), responses, failures); + } + + @Override + protected NodeApiTokenUpdateRequest newNodeRequest(ApiTokenUpdateRequest request) { + return new NodeApiTokenUpdateRequest(request); + } + + @Override + protected ApiTokenUpdateNodeResponse nodeOperation(final NodeApiTokenUpdateRequest request) { + apiTokenCache.reloadApiTokensFromIndex(); + return new ApiTokenUpdateNodeResponse(clusterService.localNode()); + } +} diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 0da8d5447d..482cb39ff0 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -141,7 +141,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final } // TODO: handle revocation different from deletion? - if (!cache.getJtis().containsKey(encryptionUtil.encrypt(jwtToken))) { + if (!cache.isValidToken(encryptionUtil.encrypt(jwtToken))) { log.error("Token is not allowlisted"); return null; } diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index d722231796..13d515ab10 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -429,10 +429,10 @@ PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( String userName = context.getUser().getName(); if (userName.startsWith("apitoken") && userName.contains(":")) { String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { // Expand the action groups Set resolvedClusterPermissions = actionGroups.resolve( - context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() + context.getApiTokenIndexListenerCache().getPermissionsForJti(jti).getClusterPerm() ); // Check for wildcard permission @@ -921,10 +921,9 @@ PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( String userName = context.getUser().getName(); if (userName.startsWith("apitoken") && userName.contains(":")) { String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { List indexPermissions = context.getApiTokenIndexListenerCache() - .getJtis() - .get(jti) + .getPermissionsForJti(jti) .getIndexPermission(); for (String concreteIndex : resolvedIndices.getAllIndices()) { diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 3de70d1302..0ee0ef3e5c 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -31,7 +31,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -65,7 +64,7 @@ public void setUp() { public void testAuthenticationFailsWhenJtiNotInCache() { String testJti = "test-jti-not-in-cache"; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - assertFalse(cache.getJtis().containsKey(testJti)); + assertFalse(cache.isValidToken(testJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -82,9 +81,7 @@ public void testExtractCredentialsPassWhenJtiInCache() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -101,9 +98,7 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjU4MiwiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjI5MDI5NDksImlhdCI6MTczNTMyNjU4MiwiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.-f45IAU4jE8EbDuthsPFm-TxtJCk8Q_uRmnG4sEkfLtjmp8mHUbSaS109YRGxKDVr3uEMgFwvkSKEFt7DHhf9A"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -122,9 +117,7 @@ public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -151,9 +144,7 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -163,16 +154,13 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { assertNull("Should return null when JTI is being used to access restricted endpoint", ac); verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); - } @Test public void testAuthenticatorNotEnabled() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class);