From 3177c349d27b0a3758dfdbba417def1b85902ed1 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 16 Dec 2024 15:32:00 -0500 Subject: [PATCH 1/5] Scaffolding for POST/DELETE/GET api tokens calls (#4921) Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 2 + .../security/action/apitokens/ApiToken.java | 224 +++++++++++++ .../action/apitokens/ApiTokenAction.java | 248 ++++++++++++++ .../action/apitokens/ApiTokenException.java | 24 ++ .../apitokens/ApiTokenIndexHandler.java | 169 ++++++++++ .../action/apitokens/ApiTokenRepository.java | 43 +++ .../auditlog/impl/AbstractAuditLog.java | 19 +- .../security/compliance/ComplianceConfig.java | 9 +- .../security/support/ConfigConstants.java | 2 + .../security/util/ParsingUtils.java | 60 ++++ .../action/apitokens/ApiTokenActionTest.java | 104 ++++++ .../apitokens/ApiTokenIndexHandlerTest.java | 304 ++++++++++++++++++ .../security/util/ParsingUtilsTest.java | 75 +++++ 13 files changed, 1275 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiToken.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java create mode 100644 src/main/java/org/opensearch/security/util/ParsingUtils.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java create mode 100644 src/test/java/org/opensearch/security/util/ParsingUtilsTest.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 57ffc4df6f..381e181942 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -131,6 +131,7 @@ import org.opensearch.search.internal.ReaderContext; import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; +import org.opensearch.security.action.apitokens.ApiTokenAction; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -635,6 +636,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); + handlers.add(new ApiTokenAction(cs, threadPool, localClient)); handlers.addAll( SecurityRestApiActions.getHandler( settings, diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java new file mode 100644 index 0000000000..2a37c8a44c --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -0,0 +1,224 @@ +/* + * 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.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +public class ApiToken implements ToXContent { + public static final String NAME_FIELD = "name"; + public static final String JTI_FIELD = "jti"; + public static final String CREATION_TIME_FIELD = "creation_time"; + public static final String CLUSTER_PERMISSIONS_FIELD = "cluster_permissions"; + public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; + public static final String INDEX_PATTERN_FIELD = "index_pattern"; + public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions"; + + private String name; + private final String jti; + private final Instant creationTime; + private List clusterPermissions; + private List indexPermissions; + + public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions) { + this.creationTime = Instant.now(); + this.name = name; + this.jti = jti; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + + } + + public ApiToken( + String description, + String jti, + List clusterPermissions, + List indexPermissions, + Instant creationTime + ) { + this.name = description; + this.jti = jti; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.creationTime = creationTime; + + } + + public static class IndexPermission implements ToXContent { + private final List indexPatterns; + private final List allowedActions; + + public IndexPermission(List indexPatterns, List allowedActions) { + this.indexPatterns = indexPatterns; + this.allowedActions = allowedActions; + } + + public List getAllowedActions() { + return allowedActions; + } + + public List getIndexPatterns() { + return indexPatterns; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.array(INDEX_PATTERN_FIELD, indexPatterns.toArray(new String[0])); + builder.array(ALLOWED_ACTIONS_FIELD, allowedActions.toArray(new String[0])); + builder.endObject(); + return builder; + } + } + + /** + * Class represents an API token. + * Expected class structure + * { + * name: "token_name", + * jti: "encrypted_token", + * creation_time: 1234567890, + * cluster_permissions: ["cluster_permission1", "cluster_permission2"], + * index_permissions: [ + * { + * index_pattern: ["index_pattern1", "index_pattern2"], + * allowed_actions: ["allowed_action1", "allowed_action2"] + * } + * ], + * expiration: 1234567890 + * } + */ + public static ApiToken fromXContent(XContentParser parser) throws IOException { + String name = null; + String jti = null; + List clusterPermissions = new ArrayList<>(); + List indexPermissions = new ArrayList<>(); + Instant creationTime = null; + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + switch (currentFieldName) { + case NAME_FIELD: + name = parser.text(); + break; + case JTI_FIELD: + jti = parser.text(); + break; + case CREATION_TIME_FIELD: + creationTime = Instant.ofEpochMilli(parser.longValue()); + break; + } + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case CLUSTER_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + clusterPermissions.add(parser.text()); + } + break; + case INDEX_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + indexPermissions.add(parseIndexPermission(parser)); + } + } + break; + } + } + } + + return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime); + } + + private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + + return new IndexPermission(indexPatterns, allowedActions); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getJti() { + return jti; + } + + public Instant getCreationTime() { + return creationTime; + } + + public List getClusterPermissions() { + return clusterPermissions; + } + + public void setClusterPermissions(List clusterPermissions) { + this.clusterPermissions = clusterPermissions; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field(NAME_FIELD, name); + xContentBuilder.field(JTI_FIELD, jti); + xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); + xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); + xContentBuilder.field(CREATION_TIME_FIELD, creationTime.toEpochMilli()); + xContentBuilder.endObject(); + return xContentBuilder; + } + + public List getIndexPermissions() { + return indexPermissions; + } + + public void setIndexPermissions(List indexPermissions) { + this.indexPermissions = indexPermissions; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java new file mode 100644 index 0000000000..a9dd54e80b --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -0,0 +1,248 @@ +/* + * 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.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; + +import org.opensearch.client.Client; +import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.service.ClusterService; +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.RestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.util.ParsingUtils; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.CREATION_TIME_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; + +public class ApiTokenAction extends BaseRestHandler { + private final ApiTokenRepository apiTokenRepository; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of( + new RestHandler.Route(POST, "/apitokens"), + new RestHandler.Route(DELETE, "/apitokens"), + new RestHandler.Route(GET, "/apitokens") + ) + ); + + public ApiTokenAction(ClusterService clusterService, ThreadPool threadPool, Client client) { + this.apiTokenRepository = new ApiTokenRepository(client, clusterService); + } + + @Override + public String getName() { + return "api_token_action"; + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { + // TODO: Authorize this API properly + switch (request.method()) { + case POST: + return handlePost(request, client); + case DELETE: + return handleDelete(request, client); + case GET: + return handleGet(request, client); + default: + throw new IllegalArgumentException(request.method() + " not supported"); + } + } + + private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { + return channel -> { + final XContentBuilder builder = channel.newBuilder(); + BytesRestResponse response; + try { + Map tokens = apiTokenRepository.getApiTokens(); + + builder.startArray(); + for (ApiToken token : tokens.values()) { + builder.startObject(); + builder.field(NAME_FIELD, token.getName()); + builder.field(CREATION_TIME_FIELD, token.getCreationTime().toEpochMilli()); + builder.endObject(); + } + builder.endArray(); + + response = new BytesRestResponse(RestStatus.OK, builder); + } catch (final Exception exception) { + builder.startObject().field("error", exception.getMessage()).endObject(); + response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + } + builder.close(); + channel.sendResponse(response); + }; + } + + private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { + return channel -> { + final XContentBuilder builder = channel.newBuilder(); + BytesRestResponse response; + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + validateRequestParameters(requestBody); + + List clusterPermissions = extractClusterPermissions(requestBody); + List indexPermissions = extractIndexPermissions(requestBody); + + String token = apiTokenRepository.createApiToken( + (String) requestBody.get(NAME_FIELD), + clusterPermissions, + indexPermissions + ); + + builder.startObject(); + builder.field("token", token); + builder.endObject(); + + response = new BytesRestResponse(RestStatus.OK, builder); + } 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); + } + builder.close(); + channel.sendResponse(response); + }; + } + + /** + * Extracts cluster permissions from the request body + */ + List extractClusterPermissions(Map requestBody) { + return ParsingUtils.safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); + } + + /** + * Extracts and builds index permissions from the request body + */ + List extractIndexPermissions(Map requestBody) { + List> indexPerms = ParsingUtils.safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList()); + } + + /** + * Creates a single index permission from a permission map + */ + ApiToken.IndexPermission createIndexPermission(Map indexPerm) { + List indexPatterns; + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (indexPatternObj instanceof String) { + indexPatterns = Collections.singletonList((String) indexPatternObj); + } else { + indexPatterns = ParsingUtils.safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); + } + + List allowedActions = ParsingUtils.safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); + + return new ApiToken.IndexPermission(indexPatterns, allowedActions); + } + + /** + * Validates the request parameters + */ + void validateRequestParameters(Map requestBody) { + if (!requestBody.containsKey(NAME_FIELD)) { + throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD); + } + + if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) { + Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD); + if (!(permissions instanceof List)) { + throw new IllegalArgumentException(CLUSTER_PERMISSIONS_FIELD + " must be an array"); + } + } + + if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) { + List> indexPermsList = ParsingUtils.safeMapList( + requestBody.get(INDEX_PERMISSIONS_FIELD), + INDEX_PERMISSIONS_FIELD + ); + validateIndexPermissionsList(indexPermsList); + } + } + + /** + * Validates the index permissions list structure + */ + void validateIndexPermissionsList(List> indexPermsList) { + for (Map indexPerm : indexPermsList) { + if (!indexPerm.containsKey(INDEX_PATTERN_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + INDEX_PATTERN_FIELD); + } + if (!indexPerm.containsKey(ALLOWED_ACTIONS_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + ALLOWED_ACTIONS_FIELD); + } + + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (!(indexPatternObj instanceof String) && !(indexPatternObj instanceof List)) { + throw new IllegalArgumentException(INDEX_PATTERN_FIELD + " must be a string or array of strings"); + } + } + } + + private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { + return channel -> { + final XContentBuilder builder = channel.newBuilder(); + BytesRestResponse response; + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + + 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); + } catch (final ApiTokenException exception) { + builder.startObject().field("error", exception.getMessage()).endObject(); + response = new BytesRestResponse(RestStatus.NOT_FOUND, builder); + } catch (final Exception exception) { + builder.startObject().field("error", exception.getMessage()).endObject(); + response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + } + builder.close(); + channel.sendResponse(response); + }; + + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java new file mode 100644 index 0000000000..398da40e64 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.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.OpenSearchException; + +public class ApiTokenException extends OpenSearchException { + public ApiTokenException(String message) { + super(message); + } + + public ApiTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java new file mode 100644 index 0000000000..488229a319 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -0,0 +1,169 @@ +/* + * 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.HashMap; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.Client; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.dlic.rest.support.Utils; +import org.opensearch.security.support.ConfigConstants; + +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; + +public class ApiTokenIndexHandler { + + private final Client client; + private final ClusterService clusterService; + private static final Logger LOGGER = LogManager.getLogger(ApiTokenIndexHandler.class); + + public ApiTokenIndexHandler(Client client, ClusterService clusterService) { + this.client = client; + this.clusterService = clusterService; + } + + public String indexTokenMetadata(ApiToken token) { + // TODO: move this out of index handler class, potentially create a layer in between baseresthandler and abstractapiaction which can + // abstract this complexity away + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); + + IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); + + ActionListener irListener = ActionListener.wrap(idxResponse -> { + LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }, (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }); + + client.index(request, irListener); + return token.getName(); + + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + + public void deleteToken(String name) throws ApiTokenException { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( + QueryBuilders.matchQuery(NAME_FIELD, name) + ).setRefresh(true); + + BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); + + long deletedDocs = response.getDeleted(); + + if (deletedDocs == 0) { + throw new ApiTokenException("No token found with name " + name); + } + LOGGER.info("Deleted " + deletedDocs + " documents"); + } + } + + public Map getTokenMetadatas() { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder()); + + SearchResponse response = client.search(searchRequest).actionGet(); + + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + + ApiToken token = ApiToken.fromXContent(parser); + tokens.put(token.getName(), token); + } + } + return tokens; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public Boolean apiTokenIndexExists() { + return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + } + + public void createApiTokenIndexIfAbsent() { + // TODO: Decide if this should be done at bootstrap + if (!apiTokenIndexExists()) { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + final Map indexSettings = ImmutableMap.of( + "index.number_of_shards", + 1, + "index.auto_expand_replicas", + "0-all" + ); + final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( + indexSettings + ); + client.admin().indices().create(createIndexRequest); + } + } + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java new file mode 100644 index 0000000000..7656e350dc --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -0,0 +1,43 @@ +/* + * 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.util.List; +import java.util.Map; + +import org.opensearch.client.Client; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.index.IndexNotFoundException; + +public class ApiTokenRepository { + private final ApiTokenIndexHandler apiTokenIndexHandler; + + public ApiTokenRepository(Client client, ClusterService clusterService) { + apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + public String createApiToken(String name, List clusterPermissions, List indexPermissions) { + apiTokenIndexHandler.createApiTokenIndexIfAbsent(); + // TODO: Implement logic of creating JTI to match against during authc/z + // TODO: Add validation on whether user is creating a token with a subset of their permissions + return apiTokenIndexHandler.indexTokenMetadata(new ApiToken(name, "test-token", clusterPermissions, indexPermissions)); + } + + public void deleteApiToken(String name) throws ApiTokenException, IndexNotFoundException { + apiTokenIndexHandler.deleteToken(name); + } + + public Map getApiTokens() throws IndexNotFoundException { + return apiTokenIndexHandler.getTokenMetadatas(); + } + +} diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index a5dd5290f6..b114b93824 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -73,6 +73,7 @@ import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; import org.opensearch.threadpool.ThreadPool; @@ -92,6 +93,7 @@ public abstract class AbstractAuditLog implements AuditLog { private final Settings settings; private volatile AuditConfig.Filter auditConfigFilter; private final String securityIndex; + private final WildcardMatcher securityIndicesMatcher; private volatile ComplianceConfig complianceConfig; private final Environment environment; private AtomicBoolean externalConfigLogged = new AtomicBoolean(); @@ -124,6 +126,13 @@ protected AbstractAuditLog( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); + // TODO: support custom api tokens index? + this.securityIndicesMatcher = WildcardMatcher.from( + List.of( + settings.get(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX), + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX + ) + ); this.environment = environment; } @@ -477,7 +486,7 @@ public void logDocumentRead(String index, String id, ShardId shardId, Map map = fieldNameValues.entrySet() .stream() @@ -544,7 +553,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index return; } - AuditCategory category = securityIndex.equals(shardId.getIndexName()) + AuditCategory category = securityIndicesMatcher.test(shardId.getIndexName()) ? AuditCategory.COMPLIANCE_INTERNAL_CONFIG_WRITE : AuditCategory.COMPLIANCE_DOC_WRITE; @@ -573,7 +582,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index try { String originalSource = null; String currentSource = null; - if (securityIndex.equals(shardId.getIndexName())) { + if (securityIndicesMatcher.test(shardId.getIndexName())) { try ( XContentParser parser = XContentHelper.createParser( NamedXContentRegistry.EMPTY, @@ -629,7 +638,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } if (!complianceConfig.shouldLogWriteMetadataOnly()) { - if (securityIndex.equals(shardId.getIndexName())) { + if (securityIndicesMatcher.test(shardId.getIndexName())) { // current source, normally not null or empty try ( XContentParser parser = XContentHelper.createParser( diff --git a/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java b/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java index b149f2604a..936cbfa920 100644 --- a/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java +++ b/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java @@ -107,6 +107,7 @@ public class ComplianceConfig { private final String auditLogIndex; private final boolean enabled; private final Supplier dateProvider; + private final WildcardMatcher securityIndicesMatcher; private ComplianceConfig( final boolean enabled, @@ -174,6 +175,7 @@ public WildcardMatcher load(String index) throws Exception { }); this.dateProvider = Optional.ofNullable(dateProvider).orElse(() -> DateTime.now(DateTimeZone.UTC)); + this.securityIndicesMatcher = WildcardMatcher.from(securityIndex, ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); } @VisibleForTesting @@ -508,7 +510,8 @@ public boolean writeHistoryEnabledForIndex(String index) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + // TODO: Add support for custom api token index? + if (this.securityIndicesMatcher.test(index)) { return logInternalConfig; } // if the index is used for audit logging, return false @@ -536,7 +539,7 @@ public boolean readHistoryEnabledForIndex(String index) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + if (securityIndicesMatcher.test(index)) { return logInternalConfig; } try { @@ -558,7 +561,7 @@ public boolean readHistoryEnabledForField(String index, String field) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + if (securityIndicesMatcher.test(index)) { return logInternalConfig; } WildcardMatcher matcher; diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index f35afc6489..2a3b898bdf 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -370,6 +370,8 @@ public enum RolesMappingResolution { // Variable for initial admin password support public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + public static final String OPENSEARCH_API_TOKENS_INDEX = ".opensearch_security_api_tokens"; + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/main/java/org/opensearch/security/util/ParsingUtils.java b/src/main/java/org/opensearch/security/util/ParsingUtils.java new file mode 100644 index 0000000000..1a33ec46b4 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/ParsingUtils.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.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ParsingUtils { + + /** + * Safely casts an Object to List with validation + */ + public static List safeStringList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof String)) { + throw new IllegalArgumentException(fieldName + " must contain only strings"); + } + } + + return list.stream().map(String.class::cast).collect(Collectors.toList()); + } + + /** + * Safely casts an Object to List> with validation + */ + @SuppressWarnings("unchecked") + public static List> safeMapList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof Map)) { + throw new IllegalArgumentException(fieldName + " must contain object entries"); + } + } + return list.stream().map(item -> (Map) item).collect(Collectors.toList()); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java new file mode 100644 index 0000000000..78a4e13dbb --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.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.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; + +public class ApiTokenActionTest { + + private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null); + + @Test + public void testCreateIndexPermission() { + Map validPermission = new HashMap<>(); + validPermission.put("index_pattern", "test-*"); + validPermission.put("allowed_actions", List.of("read")); + + ApiToken.IndexPermission result = apiTokenAction.createIndexPermission(validPermission); + + assertThat(result.getIndexPatterns(), is(List.of("test-*"))); + assertThat(result.getAllowedActions(), is(List.of("read"))); + } + + @Test + public void testValidateRequestParameters() { + Map validRequest = new HashMap<>(); + validRequest.put("name", "test-token"); + validRequest.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + apiTokenAction.validateRequestParameters(validRequest); + + // Missing name + Map missingName = new HashMap<>(); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(missingName)); + + // Invalid cluster_permissions type + Map invalidClusterPerms = new HashMap<>(); + invalidClusterPerms.put("name", "test"); + invalidClusterPerms.put("cluster_permissions", "not a list"); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(invalidClusterPerms)); + } + + @Test + public void testValidateIndexPermissionsList() { + Map validPerm = new HashMap<>(); + validPerm.put("index_pattern", "test-*"); + validPerm.put("allowed_actions", List.of("read")); + apiTokenAction.validateIndexPermissionsList(Collections.singletonList(validPerm)); + + // Missing index_pattern + Map missingPattern = new HashMap<>(); + missingPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingPattern)) + ); + + // Missing allowed_actions + Map missingActions = new HashMap<>(); + missingActions.put("index_pattern", "test-*"); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingActions)) + ); + + // Invalid index_pattern type + Map invalidPattern = new HashMap<>(); + invalidPattern.put("index_pattern", 123); + invalidPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(invalidPattern)) + ); + } + + @Test + public void testExtractClusterPermissions() { + Map requestBody = new HashMap<>(); + + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(empty())); + + requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); + } + +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java new file mode 100644 index 0000000000..1b5c295b92 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -0,0 +1,304 @@ +/* + * 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.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.apache.lucene.search.TotalHits; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.support.PlainActionFuture; +import org.opensearch.client.Client; +import org.opensearch.client.IndicesAdminClient; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.index.query.MatchQueryBuilder; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.security.support.ConfigConstants; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class ApiTokenIndexHandlerTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testCreateApiTokenIndexWhenIndexNotExist() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(false); + + indexHandler.createApiTokenIndexIfAbsent(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateIndexRequest.class); + + verify(indicesAdminClient).create(captor.capture()); + assertThat(captor.getValue().index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + } + + @Test + public void testCreateApiTokenIndexWhenIndexExists() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + indexHandler.createApiTokenIndexIfAbsent(); + + verifyNoInteractions(indicesAdminClient); + } + + @Test + public void testDeleteApiTokeCallsDeleteByQueryWithSuppliedName() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + String tokenName = "token"; + try { + indexHandler.deleteToken(tokenName); + } catch (Exception e) { + // Ignore + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteByQueryRequest.class); + verify(client).execute(eq(DeleteByQueryAction.INSTANCE), captor.capture()); + + // Captured request has the correct name + DeleteByQueryRequest capturedRequest = captor.getValue(); + MatchQueryBuilder query = (MatchQueryBuilder) capturedRequest.getSearchRequest().source().query(); + assertThat(query.fieldName(), equalTo(NAME_FIELD)); + assertThat(query.value(), equalTo(tokenName)); + } + + @Test + public void testDeleteTokenThrowsExceptionWhenNoDocumentsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + PlainActionFuture future = new PlainActionFuture<>(); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(0L); + future.onResponse(response); + when(client.execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class))).thenReturn(future); + + String tokenName = "nonexistent-token"; + try { + indexHandler.deleteToken(tokenName); + fail("Expected ApiTokenException to be thrown"); + } catch (ApiTokenException e) { + assertThat(e.getMessage(), equalTo("No token found with name " + tokenName)); + } + } + + @Test + public void testDeleteTokenSucceedsWhenDocumentIsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + // 1 deleted document + PlainActionFuture future = new PlainActionFuture<>(); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + future.onResponse(response); + when(client.execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class))).thenReturn(future); + + String tokenName = "existing-token"; + try { + indexHandler.deleteToken(tokenName); + } catch (ApiTokenException e) { + fail("Should not have thrown exception"); + } + } + + @Test + public void testIndexTokenStoresTokenPayload() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + List clusterPermissions = Arrays.asList("cluster:admin/something"); + List indexPermissions = Arrays.asList( + new ApiToken.IndexPermission( + Arrays.asList("test-index-*"), + Arrays.asList("read", "write") + ) + ); + ApiToken token = new ApiToken( + "test-token-description", + "test-jti", + clusterPermissions, + indexPermissions, + Instant.now() + ); + + // Mock the index method with ActionListener + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = + ArgumentCaptor.forClass((Class>) (Class) ActionListener.class); + + doAnswer(invocation -> { + ActionListener listener = listenerCaptor.getValue(); + listener.onResponse(new IndexResponse( + new ShardId(".opensearch_security_api_tokens", "_na_", 1), + "1", + 0, + 1, + 1, + true + )); + return null; + }).when(client).index(any(IndexRequest.class), listenerCaptor.capture()); + + indexHandler.indexTokenMetadata(token); + + // Verify the index request + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(client).index(requestCaptor.capture(), listenerCaptor.capture()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + + // verify contents + String source = capturedRequest.source().utf8ToString(); + assertThat(source, containsString("test-token-description")); + assertThat(source, containsString("test-jti")); + assertThat(source, containsString("cluster:admin/something")); + assertThat(source, containsString("test-index-*")); + } + + @Test + public void testGetTokenPayloads() throws IOException { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + // Create sample search hits + SearchHit[] hits = new SearchHit[2]; + + // First token + ApiToken token1 = new ApiToken( + "token1-description", + "jti1", + Arrays.asList("cluster:admin/something"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index1-*"), + Arrays.asList("read") + )), + Instant.now() + ); + + // Second token + ApiToken token2 = new ApiToken( + "token2-description", + "jti2", + Arrays.asList("cluster:admin/other"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index2-*"), + Arrays.asList("write") + )), + Instant.now() + ); + + // Convert tokens to XContent and create SearchHits + XContentBuilder builder1 = XContentBuilder.builder(XContentType.JSON.xContent()); + token1.toXContent(builder1, ToXContent.EMPTY_PARAMS); + hits[0] = new SearchHit(1, "1", null, null); + hits[0].sourceRef(BytesReference.bytes(builder1)); + + XContentBuilder builder2 = XContentBuilder.builder(XContentType.JSON.xContent()); + token2.toXContent(builder2, ToXContent.EMPTY_PARAMS); + hits[1] = new SearchHit(2, "2", null, null); + hits[1].sourceRef(BytesReference.bytes(builder2)); + + // Create and mock search response + SearchResponse searchResponse = mock(SearchResponse.class); + SearchHits searchHits = new SearchHits(hits, new TotalHits(2, TotalHits.Relation.EQUAL_TO), 1.0f); + when(searchResponse.getHits()).thenReturn(searchHits); + + // Mock client search call + PlainActionFuture future = new PlainActionFuture<>(); + future.onResponse(searchResponse); + when(client.search(any(SearchRequest.class))).thenReturn(future); + + // Get tokens and verify + Map resultTokens = indexHandler.getTokenMetadatas(); + + assertThat(resultTokens.size(), equalTo(2)); + assertThat(resultTokens.containsKey("token1-description"), is(true)); + assertThat(resultTokens.containsKey("token2-description"), is(true)); + + ApiToken resultToken1 = resultTokens.get("token1-description"); + assertThat(resultToken1.getJti(), equalTo("jti1")); + assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); + + ApiToken resultToken2 = resultTokens.get("token2-description"); + assertThat(resultToken2.getJti(), equalTo("jti2")); + assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); + } + +} diff --git a/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java new file mode 100644 index 0000000000..8e92ce3a39 --- /dev/null +++ b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java @@ -0,0 +1,75 @@ +/* + * 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.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; +import static org.junit.Assert.assertThrows; + +public class ParsingUtilsTest { + + @Test + public void testSafeStringList() { + List emptyResult = safeStringList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + List result = safeStringList(Arrays.asList("test1", "test2"), "test_field"); + assertThat(result, is(Arrays.asList("test1", "test2"))); + + // Not a list + assertThrows(IllegalArgumentException.class, () -> safeStringList("not a list", "test_field")); + + // List with non-string + assertThrows(IllegalArgumentException.class, () -> safeStringList(Arrays.asList("test", 123), "test_field")); + } + + @Test + public void testSafeMapList() { + List> emptyResult = safeMapList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + Map map1 = new HashMap<>(); + map1.put("key1", "value1"); + map1.put("key2", 123); + + Map map2 = new HashMap<>(); + map2.put("key3", "value3"); + map2.put("key4", true); + + List> input = Arrays.asList(map1, map2); + List> result = safeMapList(input, "test_field"); + assertThat(result, is(input)); + + // Test not a list + assertThrows(IllegalArgumentException.class, () -> safeMapList("not a list", "test_field")); + + // Test list with non-map element + assertThrows(IllegalArgumentException.class, () -> safeMapList(Arrays.asList(map1, "not a map"), "test_field")); + + List> list = safeMapList(Arrays.asList(map1, map2), "test_field"); + assertThat(list.size(), is(2)); + assertThat(list.contains(map1), is(true)); + assertThat(list.contains(map2), is(true)); + + } + +} From dacdae5b283e4d6730a978bf35f2feb5af48070e Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Fri, 20 Dec 2024 15:42:19 -0500 Subject: [PATCH 2/5] Adds JTI and expiration field support for API Tokens (#4967) Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 2 +- .../security/action/apitokens/ApiToken.java | 77 +++++++---- .../action/apitokens/ApiTokenAction.java | 40 ++++-- .../action/apitokens/ApiTokenRepository.java | 37 +++++- .../jwt/ExpiringBearerAuthToken.java | 9 ++ .../security/authtoken/jwt/JwtVendor.java | 71 ++++++++++ .../identity/SecurityTokenManager.java | 56 ++++++-- .../securityconf/DynamicConfigModel.java | 2 + .../securityconf/DynamicConfigModelV7.java | 7 + .../securityconf/impl/v7/ConfigV7.java | 51 ++++++++ .../action/apitokens/ApiTokenActionTest.java | 1 - .../apitokens/ApiTokenIndexHandlerTest.java | 20 +-- .../apitokens/ApiTokenRepositoryTest.java | 121 ++++++++++++++++++ .../action/apitokens/ApiTokenTest.java | 96 ++++++++++++++ .../security/authtoken/jwt/JwtVendorTest.java | 121 ++++++++++++++++++ .../identity/SecurityTokenManagerTest.java | 58 +++++++++ 16 files changed, 706 insertions(+), 63 deletions(-) create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 381e181942..c30ef098cb 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -636,7 +636,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, threadPool, localClient)); + handlers.add(new ApiTokenAction(cs, localClient, tokenManager)); handlers.addAll( SecurityRestApiActions.getHandler( settings, diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java index 2a37c8a44c..d8be267da3 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -16,6 +16,8 @@ import java.util.ArrayList; import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnore; + import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; @@ -28,35 +30,38 @@ public class ApiToken implements ToXContent { public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; public static final String INDEX_PATTERN_FIELD = "index_pattern"; public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions"; + public static final String EXPIRATION_FIELD = "expiration"; - private String name; + private final String name; private final String jti; private final Instant creationTime; - private List clusterPermissions; - private List indexPermissions; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; - public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions) { + public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions, Long expiration) { this.creationTime = Instant.now(); - this.name = name; this.jti = jti; + this.name = name; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; - + this.expiration = expiration; } public ApiToken( - String description, + String name, String jti, List clusterPermissions, List indexPermissions, - Instant creationTime + Instant creationTime, + Long expiration ) { - this.name = description; + this.name = name; this.jti = jti; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; this.creationTime = creationTime; - + this.expiration = expiration; } public static class IndexPermission implements ToXContent { @@ -84,6 +89,36 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.endObject(); return builder; } + + public static IndexPermission fromXContent(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + + return new IndexPermission(indexPatterns, allowedActions); + } + } /** @@ -109,6 +144,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { List clusterPermissions = new ArrayList<>(); List indexPermissions = new ArrayList<>(); Instant creationTime = null; + long expiration = 0; XContentParser.Token token; String currentFieldName = null; @@ -127,6 +163,9 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { case CREATION_TIME_FIELD: creationTime = Instant.ofEpochMilli(parser.longValue()); break; + case EXPIRATION_FIELD: + expiration = parser.longValue(); + break; } } else if (token == XContentParser.Token.START_ARRAY) { switch (currentFieldName) { @@ -146,7 +185,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { } } - return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime); + return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime, expiration); } private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { @@ -174,7 +213,6 @@ private static IndexPermission parseIndexPermission(XContentParser parser) throw } } } - return new IndexPermission(indexPatterns, allowedActions); } @@ -182,10 +220,11 @@ public String getName() { return name; } - public void setName(String name) { - this.name = name; + public Long getExpiration() { + return expiration; } + @JsonIgnore public String getJti() { return jti; } @@ -198,12 +237,8 @@ public List getClusterPermissions() { return clusterPermissions; } - public void setClusterPermissions(List clusterPermissions) { - this.clusterPermissions = clusterPermissions; - } - @Override - public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Params params) throws IOException { + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { xContentBuilder.startObject(); xContentBuilder.field(NAME_FIELD, name); xContentBuilder.field(JTI_FIELD, jti); @@ -217,8 +252,4 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Pa public List getIndexPermissions() { return indexPermissions; } - - public void setIndexPermissions(List indexPermissions) { - this.indexPermissions = indexPermissions; - } } 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 a9dd54e80b..e2e373812f 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -12,9 +12,11 @@ package org.opensearch.security.action.apitokens; import java.io.IOException; +import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; @@ -28,8 +30,7 @@ import org.opensearch.rest.BytesRestResponse; import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; -import org.opensearch.security.util.ParsingUtils; -import org.opensearch.threadpool.ThreadPool; +import org.opensearch.security.identity.SecurityTokenManager; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -37,10 +38,13 @@ import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.CREATION_TIME_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; public class ApiTokenAction extends BaseRestHandler { private final ApiTokenRepository apiTokenRepository; @@ -53,8 +57,8 @@ public class ApiTokenAction extends BaseRestHandler { ) ); - public ApiTokenAction(ClusterService clusterService, ThreadPool threadPool, Client client) { - this.apiTokenRepository = new ApiTokenRepository(client, clusterService); + public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) { + this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); } @Override @@ -94,6 +98,9 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { builder.startObject(); builder.field(NAME_FIELD, token.getName()); builder.field(CREATION_TIME_FIELD, token.getCreationTime().toEpochMilli()); + builder.field(EXPIRATION_FIELD, token.getExpiration()); + builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); + builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); builder.endObject(); } builder.endArray(); @@ -122,11 +129,12 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { String token = apiTokenRepository.createApiToken( (String) requestBody.get(NAME_FIELD), clusterPermissions, - indexPermissions + indexPermissions, + (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) ); builder.startObject(); - builder.field("token", token); + builder.field("Api Token: ", token); builder.endObject(); response = new BytesRestResponse(RestStatus.OK, builder); @@ -146,14 +154,14 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { * Extracts cluster permissions from the request body */ List extractClusterPermissions(Map requestBody) { - return ParsingUtils.safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); + return safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); } /** * Extracts and builds index permissions from the request body */ List extractIndexPermissions(Map requestBody) { - List> indexPerms = ParsingUtils.safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + List> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList()); } @@ -166,10 +174,10 @@ ApiToken.IndexPermission createIndexPermission(Map indexPerm) { if (indexPatternObj instanceof String) { indexPatterns = Collections.singletonList((String) indexPatternObj); } else { - indexPatterns = ParsingUtils.safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); + indexPatterns = safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); } - List allowedActions = ParsingUtils.safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); + List allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); return new ApiToken.IndexPermission(indexPatterns, allowedActions); } @@ -182,6 +190,13 @@ void validateRequestParameters(Map requestBody) { throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD); } + if (requestBody.containsKey(EXPIRATION_FIELD)) { + Object expiration = requestBody.get(EXPIRATION_FIELD); + if (!(expiration instanceof Long)) { + throw new IllegalArgumentException(EXPIRATION_FIELD + " must be a long"); + } + } + if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) { Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD); if (!(permissions instanceof List)) { @@ -190,10 +205,7 @@ void validateRequestParameters(Map requestBody) { } if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) { - List> indexPermsList = ParsingUtils.safeMapList( - requestBody.get(INDEX_PERMISSIONS_FIELD), - INDEX_PERMISSIONS_FIELD - ); + List> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); validateIndexPermissionsList(indexPermsList); } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index 7656e350dc..ce81aceb4b 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -14,22 +14,51 @@ import java.util.List; import java.util.Map; +import com.google.common.annotations.VisibleForTesting; + import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; public class ApiTokenRepository { private final ApiTokenIndexHandler apiTokenIndexHandler; + private final SecurityTokenManager securityTokenManager; - public ApiTokenRepository(Client client, ClusterService clusterService) { + public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + securityTokenManager = tokenManager; + } + + private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + this.apiTokenIndexHandler = apiTokenIndexHandler; + this.securityTokenManager = securityTokenManager; + } + + @VisibleForTesting + static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager); } - public String createApiToken(String name, List clusterPermissions, List indexPermissions) { + public String createApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Long expiration + ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); - // TODO: Implement logic of creating JTI to match against during authc/z // TODO: Add validation on whether user is creating a token with a subset of their permissions - return apiTokenIndexHandler.indexTokenMetadata(new ApiToken(name, "test-token", clusterPermissions, indexPermissions)); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions); + ApiToken apiToken = new ApiToken( + name, + securityTokenManager.encryptToken(token.getCompleteToken()), + clusterPermissions, + indexPermissions, + expiration + ); + apiTokenIndexHandler.indexTokenMetadata(apiToken); + return token.getCompleteToken(); } public void deleteApiToken(String name) throws ApiTokenException, IndexNotFoundException { diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java index a0879cd4da..7b321f2001 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java @@ -10,6 +10,8 @@ */ package org.opensearch.security.authtoken.jwt; +import java.time.Duration; +import java.time.Instant; import java.util.Date; import org.opensearch.identity.tokens.BearerAuthToken; @@ -26,6 +28,13 @@ public ExpiringBearerAuthToken(final String serializedToken, final String subjec this.expiresInSeconds = expiresInSeconds; } + public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry) { + super(serializedToken); + this.subject = subject; + this.expiry = expiry; + this.expiresInSeconds = Duration.between(Instant.now(), expiry.toInstant()).getSeconds(); + } + public String getSubject() { return subject; } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index e21d9257ff..75ce45912a 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,7 +11,11 @@ package org.opensearch.security.authtoken.jwt; +import java.io.IOException; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.text.ParseException; +import java.util.ArrayList; import java.util.Base64; import java.util.Date; import java.util.List; @@ -24,6 +28,9 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.security.action.apitokens.ApiToken; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -148,4 +155,68 @@ public ExpiringBearerAuthToken createJwt( return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); } + + @SuppressWarnings("removal") + public ExpiringBearerAuthToken createJwt( + final String issuer, + final String subject, + final String audience, + final long expiration, + final List clusterPermissions, + final List indexPermissions + ) throws JOSEException, ParseException, IOException { + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); + claimsBuilder.issuer(issuer); + claimsBuilder.issueTime(now); + claimsBuilder.subject(subject); + claimsBuilder.audience(audience); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + + if (clusterPermissions != null) { + final String listOfClusterPermissions = String.join(",", clusterPermissions); + claimsBuilder.claim("cp", encryptString(listOfClusterPermissions)); + } + + if (indexPermissions != null) { + List permissionStrings = new ArrayList<>(); + for (ApiToken.IndexPermission permission : indexPermissions) { + permissionStrings.add(permission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString()); + } + final String listOfIndexPermissions = String.join(",", permissionStrings); + claimsBuilder.claim("ip", encryptString(listOfIndexPermissions)); + } + + final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); + + final SignedJWT signedJwt = AccessController.doPrivileged( + (PrivilegedAction) () -> new SignedJWT(header, claimsBuilder.build()) + ); + + // Sign the JWT so it can be serialized + signedJwt.sign(signer); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + signedJwt.serialize() + "\n" + signedJwt.getHeader().toJSONObject() + "\n" + signedJwt.getJWTClaimsSet() + ); + } + + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime); + } + + /* Returns the encrypted string based on encryption settings */ + public String encryptString(final String input) { + return encryptionDecryptionUtil.encrypt(input); + } + + /* Returns the decrypted string based on encryption settings */ + public String decryptString(final String input) { + return encryptionDecryptionUtil.decrypt(input); + } } diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 8a0c3e85f1..ca5a17b6f7 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,9 +11,10 @@ package org.opensearch.security.identity; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -27,6 +28,7 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; import org.opensearch.security.securityconf.ConfigModel; @@ -50,7 +52,8 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; - private JwtVendor jwtVendor = null; + private JwtVendor oboJwtVendor = null; + private JwtVendor apiTokenJwtVendor = null; private ConfigModel configModel = null; public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { @@ -67,11 +70,14 @@ public void onConfigModelChanged(final ConfigModel configModel) { @Subscribe public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); - final Boolean enabled = oboSettings.getAsBoolean("enabled", false); - if (enabled) { - jwtVendor = createJwtVendor(oboSettings); - } else { - jwtVendor = null; + final Boolean oboEnabled = oboSettings.getAsBoolean("enabled", false); + if (oboEnabled) { + oboJwtVendor = createJwtVendor(oboSettings); + } + final Settings apiTokenSettings = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettings.getAsBoolean("enabled", false); + if (apiTokenEnabled) { + apiTokenJwtVendor = createJwtVendor(apiTokenSettings); } } @@ -86,7 +92,11 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return jwtVendor != null && configModel != null; + return oboJwtVendor != null && configModel != null; + } + + public boolean issueApiTokenAllowed() { + return apiTokenJwtVendor != null && configModel != null; } @Override @@ -116,13 +126,13 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); try { - return jwtVendor.createJwt( + return oboJwtVendor.createJwt( cs.getClusterName().value(), user.getName(), claims.getAudience(), claims.getExpiration(), - mappedRoles.stream().collect(Collectors.toList()), - user.getRoles().stream().collect(Collectors.toList()), + new ArrayList<>(mappedRoles), + new ArrayList<>(user.getRoles()), false ); } catch (final Exception ex) { @@ -131,6 +141,30 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } } + public ExpiringBearerAuthToken issueApiToken( + final String name, + final Long expiration, + final List clusterPermissions, + final List indexPermissions + ) { + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + + try { + return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration, clusterPermissions, indexPermissions); + } catch (final Exception ex) { + logger.error("Error creating Api Token for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate Api Token"); + } + } + + public String encryptToken(final String token) { + return apiTokenJwtVendor.encryptString(token); + } + + public String decryptString(final String input) { + return apiTokenJwtVendor.decryptString(input); + } + @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java index 064f555a75..0d56a41c23 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java @@ -110,6 +110,8 @@ public abstract class DynamicConfigModel { public abstract Settings getDynamicOnBehalfOfSettings(); + public abstract Settings getDynamicApiTokenSettings(); + protected final Map authImplMap = new HashMap<>(); public DynamicConfigModel() { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 4bc9e82882..9c90e2341f 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -234,6 +234,13 @@ public Settings getDynamicOnBehalfOfSettings() { .build(); } + @Override + public Settings getDynamicApiTokenSettings() { + return Settings.builder() + .put(Settings.builder().loadFromSource(config.dynamic.api_tokens.configAsJson(), XContentType.JSON).build()) + .build(); + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 77fb973a52..6555c0838d 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -86,6 +86,7 @@ public static class Dynamic { public String transport_userrname_attribute; public boolean do_not_fail_on_forbidden_empty; public OnBehalfOfSettings on_behalf_of = new OnBehalfOfSettings(); + public ApiTokenSettings api_tokens = new ApiTokenSettings(); @Override public String toString() { @@ -101,6 +102,8 @@ public String toString() { + authz + ", on_behalf_of=" + on_behalf_of + + ", api_tokens=" + + api_tokens + "]"; } } @@ -495,4 +498,52 @@ public String toString() { } } + public static class ApiTokenSettings { + @JsonProperty("enabled") + private Boolean enabled = Boolean.FALSE; + @JsonProperty("signing_key") + private String signingKey; + @JsonProperty("encryption_key") + private String encryptionKey; + + @JsonIgnore + public String configAsJson() { + try { + return DefaultObjectMapper.writeValueAsString(this, false); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean oboEnabled) { + this.enabled = oboEnabled; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + public void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + } + + @Override + public String toString() { + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + } + + } + } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 78a4e13dbb..483fe7c9d7 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -100,5 +100,4 @@ public void testExtractClusterPermissions() { requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); } - } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java index 1b5c295b92..7e03c14851 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -192,10 +192,11 @@ public void testIndexTokenStoresTokenPayload() { ); ApiToken token = new ApiToken( "test-token-description", - "test-jti", + "test-token-jti", clusterPermissions, indexPermissions, - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Mock the index method with ActionListener @@ -216,6 +217,7 @@ public void testIndexTokenStoresTokenPayload() { return null; }).when(client).index(any(IndexRequest.class), listenerCaptor.capture()); + indexHandler.indexTokenMetadata(token); // Verify the index request @@ -228,8 +230,8 @@ public void testIndexTokenStoresTokenPayload() { // verify contents String source = capturedRequest.source().utf8ToString(); assertThat(source, containsString("test-token-description")); - assertThat(source, containsString("test-jti")); assertThat(source, containsString("cluster:admin/something")); + assertThat(source, containsString("test-token-jti")); assertThat(source, containsString("test-index-*")); } @@ -243,25 +245,27 @@ public void testGetTokenPayloads() throws IOException { // First token ApiToken token1 = new ApiToken( "token1-description", - "jti1", + "token1-jti", Arrays.asList("cluster:admin/something"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index1-*"), Arrays.asList("read") )), - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Second token ApiToken token2 = new ApiToken( "token2-description", - "jti2", + "token2-jti", Arrays.asList("cluster:admin/other"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index2-*"), Arrays.asList("write") )), - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Convert tokens to XContent and create SearchHits @@ -293,11 +297,9 @@ public void testGetTokenPayloads() throws IOException { assertThat(resultTokens.containsKey("token2-description"), is(true)); ApiToken resultToken1 = resultTokens.get("token1-description"); - assertThat(resultToken1.getJti(), equalTo("jti1")); assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); ApiToken resultToken2 = resultTokens.get("token2-description"); - assertThat(resultToken2.getJti(), equalTo("jti2")); assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java new file mode 100644 index 0000000000..03a2e2c30e --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -0,0 +1,121 @@ +/* + * 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.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ApiTokenRepositoryTest { + @Mock + private SecurityTokenManager securityTokenManager; + + @Mock + private ApiTokenIndexHandler apiTokenIndexHandler; + + private ApiTokenRepository repository; + + @Before + public void setUp() { + apiTokenIndexHandler = mock(ApiTokenIndexHandler.class); + securityTokenManager = mock(SecurityTokenManager.class); + repository = ApiTokenRepository.forTest(apiTokenIndexHandler, securityTokenManager); + } + + @Test + public void testDeleteApiToken() throws ApiTokenException { + String tokenName = "test-token"; + + repository.deleteApiToken(tokenName); + + verify(apiTokenIndexHandler).deleteToken(tokenName); + } + + @Test + public void testGetApiTokens() throws IndexNotFoundException { + Map expectedTokens = new HashMap<>(); + expectedTokens.put("token1", new ApiToken("token1", "token1-jti", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); + when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(expectedTokens); + + Map result = repository.getApiTokens(); + + assertThat(result, equalTo(expectedTokens)); + verify(apiTokenIndexHandler).getTokenMetadatas(); + } + + @Test + public void testCreateApiToken() { + String tokenName = "test-token"; + List clusterPermissions = Arrays.asList("cluster:admin"); + List indexPermissions = Arrays.asList( + new ApiToken.IndexPermission(Arrays.asList("test-*"), Arrays.asList("read")) + ); + Long expiration = 3600L; + + String completeToken = "complete-token"; + String encryptedToken = "encrypted-token"; + ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); + when(bearerToken.getCompleteToken()).thenReturn(completeToken); + when(securityTokenManager.issueApiToken(any(), any(), any(), any())).thenReturn(bearerToken); + when(securityTokenManager.encryptToken(completeToken)).thenReturn(encryptedToken); + + String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); + + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); + verify(securityTokenManager).issueApiToken(any(), any(), any(), any()); + verify(securityTokenManager).encryptToken(completeToken); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getJti().equals(encryptedToken) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + ) + ); + assertThat(result, equalTo(completeToken)); + } + + @Test(expected = IndexNotFoundException.class) + public void testGetApiTokensThrowsIndexNotFoundException() throws IndexNotFoundException { + when(apiTokenIndexHandler.getTokenMetadatas()).thenThrow(new IndexNotFoundException("test-index")); + + repository.getApiTokens(); + + } + + @Test(expected = ApiTokenException.class) + public void testDeleteApiTokenThrowsApiTokenException() throws ApiTokenException { + String tokenName = "test-token"; + doThrow(new ApiTokenException("Token not found")).when(apiTokenIndexHandler).deleteToken(tokenName); + + repository.deleteApiToken(tokenName); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java new file mode 100644 index 0000000000..4951507359 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -0,0 +1,96 @@ +/* + * 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.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.client.Client; +import org.opensearch.client.IndicesAdminClient; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiTokenTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testIndexPermissionToStringFromString() throws IOException { + String indexPermissionString = "{\"index_pattern\":[\"index1\",\"index2\"],\"allowed_actions\":[\"action1\",\"action2\"]}"; + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission( + Arrays.asList("index1", "index2"), + Arrays.asList("action1", "action2") + ); + assertThat( + indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString(), + equalTo(indexPermissionString) + ); + + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, indexPermissionString); + + ApiToken.IndexPermission indexPermissionFromString = ApiToken.IndexPermission.fromXContent(parser); + assertThat(indexPermissionFromString.getIndexPatterns(), equalTo(List.of("index1", "index2"))); + assertThat(indexPermissionFromString.getAllowedActions(), equalTo(List.of("action1", "action2"))); + } + +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index ca8b4ad14d..48aae6f9b8 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -11,6 +11,7 @@ package org.opensearch.security.authtoken.jwt; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; @@ -30,11 +31,19 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.support.ConfigConstants; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jwt.SignedJWT; +import joptsimple.internal.Strings; import org.mockito.ArgumentCaptor; import static org.hamcrest.MatcherAssert.assertThat; @@ -270,4 +279,116 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String[] parts = logMessage.split("\\."); assertTrue(parts.length >= 3); } + + @Test + public void testCreateJwtForApiTokenSuccess() throws Exception { + final String issuer = "cluster_0"; + final String subject = "test-token"; + final String audience = "test-token"; + final List clusterPermissions = List.of("cluster:admin/*"); + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission(List.of("*"), List.of("read")); + final List indexPermissions = List.of(indexPermission); + final String expectedClusterPermissions = "cluster:admin/*"; + final String expectedIndexPermissions = indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) + .toString(); + + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = "1234567890123456"; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt( + issuer, + subject, + audience, + Long.MAX_VALUE, + clusterPermissions, + indexPermissions + ); + + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); + + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo(issuer)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo(subject)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[" + audience + "]")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iat"), is(notNullValue())); + // Allow for millisecond to second conversion flexibility + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime() / 1000, equalTo(Long.MAX_VALUE / 1000)); + + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + assertThat( + encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), + equalTo(expectedClusterPermissions) + ); + assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); + + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) + ); + ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); + + // Index permission deserialization works as expected + assertThat(indexPermission1.getIndexPatterns(), equalTo(indexPermission.getIndexPatterns())); + assertThat(indexPermission1.getAllowedActions(), equalTo(indexPermission.getAllowedActions())); + } + + @Test + public void testEncryptJwtCorrectly() { + String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); + String token = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJkZXJlayI6ImlzIGF3ZXNvbWUifQ.aPp9mSaBRBUzMJ8V_MYWUs8UoGYnJDNVriu3B9MRJpPNZtOhnIfATE0Ghmms2bGRNw9rmyRn1VIDQRmxSOTu3w"; + String expectedEncryptedToken = + "k3JQNRXR57Y4V4W1LNkpEP7FTJZos7fySJDJDGuBQXe7pi9aiEIGJ7JqjezssGRZ1AZGD/QTPQ0jjaV+rEICxBO9oyfTYWIoDdnAg5LijqPAzaULp48hi+/dqXXAAhi1zIlCSjqTDoZMTyjFxq4aRlPLjjQFuVxR3gIDMNnAUnvmFu5xh5AiVeKa1dwGy5X34Ou2i9pnQzmEDJDnf6mh7w2ODkDThJGh8JUlsUlfZEq6NwVN1XNyOr2IhPd3IZYUMgN3vWHyfjs6uwQNyHKHHcxIj4P8bJXLIGxJy3+LV5Y="; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + LongSupplier currentTime = () -> (long) 100; + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + assertThat(jwtVendor.encryptString(token), equalTo(expectedEncryptedToken)); + } + + @Test + public void testEncryptDecryptClusterIndexPermissionsCorrectly() throws IOException { + String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); + String clusterPermissions = "cluster:admin/*,cluster:*"; + String encryptedClusterPermissions = "P+KGUkpANJHzHGKVSqJhIyHOKS+JCLOanxCOBWSgZNk="; + // "{\"index_pattern\":[\"*\"],\"allowed_actions\":[\"read\"]},{\"index_pattern\":[\".*\"],\"allowed_actions\":[\"write\"]}" + String indexPermissions = Strings.join( + List.of( + new ApiToken.IndexPermission(List.of("*"), List.of("read")).toXContent( + XContentFactory.jsonBuilder(), + ToXContent.EMPTY_PARAMS + ).toString(), + new ApiToken.IndexPermission(List.of(".*"), List.of("write")).toXContent( + XContentFactory.jsonBuilder(), + ToXContent.EMPTY_PARAMS + ).toString() + ), + "," + ); + String encryptedIndexPermissions = + "Y9ssHcl6spHC2/zy+L1P0y8e2+T+jGgXcP02DWGeTMk/3KiI4Ik0Df7oXMf9l/Ba0emk9LClnHsJi8iFwRh7ii1Pxb3CTHS/d+p7a3bA6rtJjgOjGlbjdWTdj4+87uBJynsR5CAlUMLeTrjbPe/nWw=="; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + LongSupplier currentTime = () -> (long) 100; + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + + // encrypt decrypt cluster permissions + assertThat(jwtVendor.encryptString(clusterPermissions), equalTo(encryptedClusterPermissions)); + assertThat(jwtVendor.decryptString(encryptedClusterPermissions), equalTo(clusterPermissions)); + + // encrypt decrypt index permissions + assertThat(jwtVendor.encryptString(indexPermissions), equalTo(encryptedIndexPermissions)); + assertThat(jwtVendor.decryptString(encryptedIndexPermissions), equalTo(indexPermissions)); + } + + @Test + public void testKeyTooShortThrowsException() { + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); + Settings settings = Settings.builder().put("signing_key", tooShortKey).put("encryption_key", claimsEncryptionKey).build(); + final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings, Optional.empty()); }); + + assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); + } + } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index d686b145b2..7ecbb6da34 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -81,6 +81,7 @@ public void after() { verifyNoMoreInteractions(userService); } + @Test public void onConfigModelChanged_oboNotSupported() { final ConfigModel configModel = mock(ConfigModel.class); @@ -107,6 +108,7 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { final Settings settings = Settings.builder().put("enabled", false).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); tokenManager.onDynamicConfigModelChanged(dcm); assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); @@ -119,6 +121,7 @@ private DynamicConfigModel createMockJwtVendorInTokenManager() { final Settings settings = Settings.builder().put("enabled", true).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); tokenManager.onDynamicConfigModelChanged(dcm); return dcm; @@ -245,4 +248,59 @@ public void issueOnBehalfOfToken_success() throws Exception { verify(cs).getClusterName(); verify(threadPool).getThreadContext(); } + + @Test + public void issueApiToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void encryptCallsJwtEncrypt() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void testEncryptTokenCallsJwtEncrypt() throws Exception { + String tokenToEncrypt = "test-token"; + String encryptedToken = "encrypted-test-token"; + createMockJwtVendorInTokenManager(); + when(jwtVendor.encryptString(tokenToEncrypt)).thenReturn(encryptedToken); + + String result = tokenManager.encryptToken(tokenToEncrypt); + + assertThat(result, equalTo(encryptedToken)); + verify(jwtVendor).encryptString(tokenToEncrypt); + } } From 79f0c461e10078890171b437bf4c3adc092716ba Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 4 Feb 2025 15:29:08 -0500 Subject: [PATCH 3/5] Api token authc/z implementation with Cache (#4992) Signed-off-by: Derek Ho --- .../privileges/ActionPrivilegesTest.java | 158 +++++++++- .../security/privileges/IndexPatternTest.java | 4 +- .../RestEndpointPermissionTests.java | 24 +- .../dlsfls/DlsFlsLegacyHeadersTest.java | 19 +- .../dlsfls/DocumentPrivilegesTest.java | 18 +- .../privileges/dlsfls/FieldMaskingTest.java | 6 +- .../dlsfls/FieldPrivilegesTest.java | 6 +- .../security/OpenSearchSecurityPlugin.java | 15 +- .../security/action/apitokens/ApiToken.java | 21 +- .../action/apitokens/ApiTokenAction.java | 96 ++++-- .../action/apitokens/ApiTokenRepository.java | 51 +++- .../apitokens/ApiTokenUpdateAction.java | 24 ++ .../apitokens/ApiTokenUpdateNodeResponse.java | 28 ++ .../apitokens/ApiTokenUpdateRequest.java | 35 +++ .../apitokens/ApiTokenUpdateResponse.java | 60 ++++ .../action/apitokens/Permissions.java | 35 +++ .../TransportApiTokenUpdateAction.java | 105 +++++++ .../auditlog/impl/AbstractAuditLog.java | 34 ++- .../security/authtoken/jwt/JwtVendor.java | 124 +------- .../jwt/claims/ApiJwtClaimsBuilder.java | 19 ++ .../jwt/claims/JwtClaimsBuilder.java | 69 +++++ .../jwt/claims/OBOJwtClaimsBuilder.java | 39 +++ .../security/http/ApiTokenAuthenticator.java | 224 ++++++++++++++ .../identity/SecurityTokenManager.java | 111 ++++--- .../security/privileges/ActionPrivileges.java | 261 ++++++++++++++--- ...ssionBasedPrivilegesEvaluationContext.java | 71 +++++ .../PrivilegesEvaluationContext.java | 56 +--- .../privileges/PrivilegesEvaluator.java | 21 +- .../RoleBasedPrivilegesEvaluationContext.java | 84 ++++++ .../securityconf/DynamicConfigFactory.java | 8 +- .../securityconf/DynamicConfigModelV7.java | 24 +- .../securityconf/impl/v7/ConfigV7.java | 16 +- .../security/ssl/util/ExceptionUtils.java | 4 + .../security/util/AuthTokenUtils.java | 4 + .../action/apitokens/ApiTokenActionTest.java | 3 +- .../apitokens/ApiTokenAuthenticatorTest.java | 197 +++++++++++++ .../apitokens/ApiTokenIndexHandlerTest.java | 4 - .../apitokens/ApiTokenRepositoryTest.java | 81 ++++- .../authtoken/jwt/AuthTokenUtilsTest.java | 11 + .../security/authtoken/jwt/JwtVendorTest.java | 276 +++++------------- .../identity/SecurityTokenManagerTest.java | 136 ++++++--- .../RestLayerPrivilegesEvaluatorTest.java | 4 +- .../SystemIndexAccessEvaluatorTest.java | 4 +- 43 files changed, 1952 insertions(+), 638 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/Permissions.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java create mode 100644 src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java create mode 100644 src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java create mode 100644 src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java create mode 100644 src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java create mode 100644 src/main/java/org/opensearch/security/privileges/PermissionBasedPrivilegesEvaluationContext.java create mode 100644 src/main/java/org/opensearch/security/privileges/RoleBasedPrivilegesEvaluationContext.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index 1f60cf92d5..bfe6e600c9 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -38,14 +38,20 @@ import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; +import org.mockito.Mockito; + import static org.hamcrest.MatcherAssert.assertThat; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isAllowed; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; @@ -280,6 +286,69 @@ public void hasAny_wildcard() throws Exception { isForbidden(missingPrivileges("cluster:whatever")) ); } + + @Test + public void apiToken_explicit_failsWithWildcard() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("*"), List.of()) + ); + // Explicit fails + assertThat( + subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), + isForbidden(missingPrivileges("cluster:whatever")) + ); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithExactMatch() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("cluster:whatever"), List.of()) + ); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever", "cluster:other")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + + SecurityDynamicConfiguration config = SecurityDynamicConfiguration.fromYaml( + "CLUSTER_ALL:\n allowed_actions:\n - \"cluster:*\"", + CType.ACTIONGROUPS + ); + + FlattenedActionGroups actionGroups = new FlattenedActionGroups(config); + ActionPrivileges subject = new ActionPrivileges(roles, actionGroups, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("CLUSTER_ALL"), List.of()) + ); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:monitor/main"), isAllowed()); + } } /** @@ -314,9 +383,20 @@ public void positive_full() throws Exception { assertThat(result, isAllowed()); } + @Test + public void apiTokens_positive_full() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isAllowed()); + } + @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11", "index_a12")); if (covers(ctx, "index_a11", "index_a12")) { @@ -330,7 +410,7 @@ public void positive_partial() throws Exception { @Test public void positive_partial2() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -363,14 +443,26 @@ public void positive_noLocal() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } + @Test + public void apiToken_negative_noPermissions() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of(), List.of(new ApiToken.IndexPermission(List.of(), List.of()))) + ); + + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isForbidden()); + } + @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -382,7 +474,7 @@ public void negative_wrongAction() throws Exception { @Test public void positive_hasExplicit_full() { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(ctx, requiredActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -397,7 +489,21 @@ public void positive_hasExplicit_full() { } } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + @Test + public void apiTokens_positive_hasExplicit_full() { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + + PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(context, requiredActions, resolved("index_a11")); + + assertThat(result, isForbidden()); + + } + + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -522,7 +628,7 @@ public static class DataStreams { @Test public void positive_full() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); if (covers(ctx, "data_stream_a11")) { assertThat(result, isAllowed()); @@ -538,7 +644,7 @@ public void positive_full() throws Exception { @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -569,19 +675,19 @@ public void positive_partial() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(otherActions))); } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -1039,10 +1145,15 @@ static SecurityDynamicConfiguration createRoles(int numberOfRoles, int n } } - static PrivilegesEvaluationContext ctx(String... roles) { - User user = new User("test_user"); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return ctxWithUserName("test-user", roles); + } + + static RoleBasedPrivilegesEvaluationContext ctxWithUserName(String userName, String... roles) { + User user = new User(userName); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + ApiTokenRepository mockRepository = Mockito.mock(ApiTokenRepository.class); + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, @@ -1054,10 +1165,25 @@ static PrivilegesEvaluationContext ctx(String... roles) { ); } - static PrivilegesEvaluationContext ctxByUsername(String username) { + static PermissionBasedPrivilegesEvaluationContext ctxForApiToken(String userName, Permissions permissions) { + User user = new User(userName); + user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); + return new PermissionBasedPrivilegesEvaluationContext( + user, + null, + null, + null, + null, + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), + null, + permissions + ); + } + + static RoleBasedPrivilegesEvaluationContext ctxByUsername(String username) { User user = new User(username); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java index e098a605e5..246d28d542 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java @@ -231,14 +231,14 @@ public void equals() { assertFalse(a1.equals(a1.toString())); } - private static PrivilegesEvaluationContext ctx() { + private static RoleBasedPrivilegesEvaluationContext ctx() { IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); IndexResolverReplacer indexResolverReplacer = new IndexResolverReplacer(indexNameExpressionResolver, () -> CLUSTER_STATE, null); User user = new User("test_user"); user.addAttributes(ImmutableMap.of("attrs.a11", "a11")); user.addAttributes(ImmutableMap.of("attrs.year", "year")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), "indices:action/test", diff --git a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java index 1e61aa0206..6576a84fb4 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java @@ -188,13 +188,13 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { .collect(Collectors.toList()); for (final Endpoint endpoint : noSslEndpoints) { final String permission = ENDPOINTS_WITH_PERMISSIONS.get(endpoint).build(); - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); Assert.assertTrue(endpoint.name(), actionPrivileges.hasExplicitClusterPrivilege(ctx, permission).isAllowed()); assertHasNoPermissionsForRestApiAdminOnePermissionRole(endpoint, ctx); } // verify SSL endpoint with 2 actions for (final String sslAction : ImmutableSet.of(CERTS_INFO_ACTION, RELOAD_CERTS_ACTION)) { - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.SSL); Assert.assertTrue( Endpoint.SSL + "/" + sslAction, @@ -203,7 +203,7 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.SSL, ctx); } // verify CONFIG endpoint with 1 action - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.CONFIG); Assert.assertTrue( Endpoint.SSL + "/" + SECURITY_CONFIG_UPDATE, @@ -212,7 +212,10 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.CONFIG, ctx); } - void assertHasNoPermissionsForRestApiAdminOnePermissionRole(final Endpoint allowEndpoint, final PrivilegesEvaluationContext ctx) { + void assertHasNoPermissionsForRestApiAdminOnePermissionRole( + final Endpoint allowEndpoint, + final RoleBasedPrivilegesEvaluationContext ctx + ) { final Collection noPermissionEndpoints = ENDPOINTS_WITH_PERMISSIONS.keySet() .stream() .filter(e -> e != allowEndpoint) @@ -250,8 +253,17 @@ static SecurityDynamicConfiguration createRolesConfig() throws IOExcepti return SecurityDynamicConfiguration.fromNode(rolesNode, CType.ROLES, 2, 0, 0); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.copyOf(roles), null, null, null, null, null, null); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( + new User("test_user"), + ImmutableSet.copyOf(roles), + null, + null, + null, + null, + null, + null + ); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java index 2c8e6de587..65b2f30b3a 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java @@ -35,7 +35,7 @@ import org.opensearch.index.query.RangeQueryBuilder; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.search.internal.ShardSearchRequest; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.Base64Helper; @@ -55,6 +55,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; public class DlsFlsLegacyHeadersTest { static NamedXContentRegistry xContentRegistry = new NamedXContentRegistry( @@ -255,11 +256,11 @@ public void performHeaderDecoration_oldNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -277,7 +278,7 @@ public void performHeaderDecoration_actionRequest() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // SearchRequest does extend ActionRequest, thus the headers must not be set @@ -296,11 +297,11 @@ public void performHeaderDecoration_newNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_3_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -337,7 +338,7 @@ public void prepare_ccs() throws Exception { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - PrivilegesEvaluationContext ctx = new PrivilegesEvaluationContext( + RoleBasedPrivilegesEvaluationContext ctx = new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of("test_role"), null, @@ -352,11 +353,11 @@ public void prepare_ccs() throws Exception { assertTrue(threadContext.getResponseHeaders().containsKey(ConfigConstants.OPENDISTRO_SECURITY_DLS_QUERY_HEADER)); } - static PrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { + static RoleBasedPrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java index 97a0ddb69e..f81fe39d5d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java @@ -52,8 +52,8 @@ import org.opensearch.index.query.QueryBuilders; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -121,7 +121,7 @@ public static class IndicesAndAliases_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -518,7 +518,7 @@ public IndicesAndAliases_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -566,7 +566,7 @@ public static class IndicesAndAliases_isUnrestricted { final User user; final IndicesSpec indicesSpec; final IndexResolverReplacer.Resolved resolvedIndices; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -833,7 +833,7 @@ public IndicesRequest indices(String... strings) { return this; } }); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -874,7 +874,7 @@ public static class DataStreams_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -1118,7 +1118,7 @@ public DataStreams_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -1146,7 +1146,9 @@ public void invalidQuery() throws Exception { @Test(expected = PrivilegesEvaluationException.class) public void invalidTemplatedQuery() throws Exception { DocumentPrivileges.DlsQuery.create("{\"invalid\": \"totally ${attr.foo}\"}", xContentRegistry) - .evaluate(new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null)); + .evaluate( + new RoleBasedPrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null) + ); } @Test diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java index 7f4c5bacf2..97768a6faa 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java @@ -23,7 +23,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -114,8 +114,8 @@ static FieldMasking createSubject(SecurityDynamicConfiguration roleConfi ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java index 54a32e9972..731c910fc8 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java @@ -22,7 +22,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -149,8 +149,8 @@ static FieldPrivileges createSubject(SecurityDynamicConfiguration roleCo ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 67efe1ecbd..1d5091ca04 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -133,6 +133,9 @@ import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.apitokens.ApiTokenAction; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +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; @@ -256,6 +259,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; + private volatile ApiTokenRepository ar; private volatile AdminDNs adminDns; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); @@ -645,7 +649,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, localClient, tokenManager)); + handlers.add(new ApiTokenAction(ar)); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -687,6 +691,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,6 +724,7 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1105,6 +1111,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); tokenManager = new SecurityTokenManager(cs, threadPool, userService); + ar = new ApiTokenRepository(localClient, clusterService, tokenManager); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1120,7 +1127,8 @@ public Collection createComponents( privilegesInterceptor, cih, irr, - namedXContentRegistry.get() + namedXContentRegistry.get(), + ar ); dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); @@ -1162,7 +1170,7 @@ public Collection createComponents( configPath, compatConfig ); - dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher); + dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher, ar); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); @@ -1212,6 +1220,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(ar); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java index d8be267da3..6a81ad9f4d 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -16,15 +16,12 @@ import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.annotation.JsonIgnore; - import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; public class ApiToken implements ToXContent { public static final String NAME_FIELD = "name"; - public static final String JTI_FIELD = "jti"; public static final String CREATION_TIME_FIELD = "creation_time"; public static final String CLUSTER_PERMISSIONS_FIELD = "cluster_permissions"; public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; @@ -33,15 +30,13 @@ public class ApiToken implements ToXContent { public static final String EXPIRATION_FIELD = "expiration"; private final String name; - private final String jti; private final Instant creationTime; private final List clusterPermissions; private final List indexPermissions; private final long expiration; - public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions, Long expiration) { + public ApiToken(String name, List clusterPermissions, List indexPermissions, Long expiration) { this.creationTime = Instant.now(); - this.jti = jti; this.name = name; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; @@ -50,14 +45,12 @@ public ApiToken(String name, String jti, List clusterPermissions, List clusterPermissions, List indexPermissions, Instant creationTime, Long expiration ) { this.name = name; - this.jti = jti; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; this.creationTime = creationTime; @@ -140,7 +133,6 @@ public static IndexPermission fromXContent(XContentParser parser) throws IOExcep */ public static ApiToken fromXContent(XContentParser parser) throws IOException { String name = null; - String jti = null; List clusterPermissions = new ArrayList<>(); List indexPermissions = new ArrayList<>(); Instant creationTime = null; @@ -157,9 +149,6 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { case NAME_FIELD: name = parser.text(); break; - case JTI_FIELD: - jti = parser.text(); - break; case CREATION_TIME_FIELD: creationTime = Instant.ofEpochMilli(parser.longValue()); break; @@ -185,7 +174,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { } } - return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime, expiration); + return new ApiToken(name, clusterPermissions, indexPermissions, creationTime, expiration); } private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { @@ -224,11 +213,6 @@ public Long getExpiration() { return expiration; } - @JsonIgnore - public String getJti() { - return jti; - } - public Instant getCreationTime() { return creationTime; } @@ -241,7 +225,6 @@ public List getClusterPermissions() { public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { xContentBuilder.startObject(); xContentBuilder.field(NAME_FIELD, name); - xContentBuilder.field(JTI_FIELD, jti); xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); xContentBuilder.field(CREATION_TIME_FIELD, creationTime.toEpochMilli()); 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..0b6a7b320a 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -20,17 +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; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -47,7 +48,8 @@ import static org.opensearch.security.util.ParsingUtils.safeStringList; public class ApiTokenAction extends BaseRestHandler { - private final ApiTokenRepository apiTokenRepository; + private ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); private static final List ROUTES = addRoutesPrefix( ImmutableList.of( @@ -57,8 +59,8 @@ public class ApiTokenAction extends BaseRestHandler { ) ); - public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) { - this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); + public ApiTokenAction(ApiTokenRepository apiTokenRepository) { + this.apiTokenRepository = apiTokenRepository; } @Override @@ -133,20 +135,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 +253,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/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index ce81aceb4b..b1f99bdbd6 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -13,18 +13,59 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; + +import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; public class ApiTokenRepository { private final ApiTokenIndexHandler apiTokenIndexHandler; private final SecurityTokenManager securityTokenManager; + private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); + + private final Map jtis = new ConcurrentHashMap<>(); + + void reloadApiTokensFromIndex() { + Map tokensFromIndex = apiTokenIndexHandler.getTokenMetadatas(); + jtis.keySet().removeIf(key -> !tokensFromIndex.containsKey(key)); + tokensFromIndex.forEach( + (key, apiToken) -> jtis.put(key, new Permissions(apiToken.getClusterPermissions(), apiToken.getIndexPermissions())) + ); + } + + public Permissions getApiTokenPermissionsForUser(User user) { + String name = user.getName(); + if (name.startsWith(API_TOKEN_USER_PREFIX)) { + String jti = user.getName().split(API_TOKEN_USER_PREFIX)[1]; + if (isValidToken(jti)) { + return getPermissionsForJti(jti); + } + } + return new Permissions(List.of(), List.of()); + } + + public Permissions getPermissionsForJti(String jti) { + return jtis.get(jti); + } + + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); + } + + public Map getJtis() { + return jtis; + } public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); @@ -49,14 +90,8 @@ public String createApiToken( ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); // TODO: Add validation on whether user is creating a token with a subset of their permissions - ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions); - ApiToken apiToken = new ApiToken( - name, - securityTokenManager.encryptToken(token.getCompleteToken()), - clusterPermissions, - indexPermissions, - expiration - ); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); + ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration); apiTokenIndexHandler.indexTokenMetadata(apiToken); return token.getCompleteToken(); } 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/Permissions.java b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java new file mode 100644 index 0000000000..9b684cebde --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/Permissions.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. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.Collections; +import java.util.List; + +public class Permissions { + private final List clusterPerm; + private final List indexPermission; + + public Permissions(List clusterPerm, List indexPermission) { + this.clusterPerm = clusterPerm; + this.indexPermission = indexPermission; + } + + public Permissions() { + this.clusterPerm = Collections.emptyList(); + this.indexPermission = Collections.emptyList(); + } + + public List getClusterPerm() { + return clusterPerm; + } + + public List getIndexPermission() { + return indexPermission; + } +} 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..c486deab71 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,105 @@ +/* + * 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 ApiTokenRepository apiTokenRepository; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + ApiTokenRepository apiTokenRepository + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenRepository = apiTokenRepository; + 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) { + apiTokenRepository.reloadApiTokensFromIndex(); + return new ApiTokenUpdateNodeResponse(clusterService.localNode()); + } +} diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 8bf8f63dde..9a16cd8bfd 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -584,22 +584,24 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index originalSource = "{}"; } if (securityIndicesMatcher.test(shardId.getIndexName())) { - try ( - XContentParser parser = XContentHelper.createParser( - NamedXContentRegistry.EMPTY, - THROW_UNSUPPORTED_OPERATION, - originalResult.internalSourceRef(), - XContentType.JSON - ) - ) { - Object base64 = parser.map().values().iterator().next(); - if (base64 instanceof String) { - originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); - } else { - originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + if (originalSource == null) { + try ( + XContentParser parser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + originalResult.internalSourceRef(), + XContentType.JSON + ) + ) { + Object base64 = parser.map().values().iterator().next(); + if (base64 instanceof String) { + originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); + } else { + originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + } + } catch (Exception e) { + log.error(e.toString()); } - } catch (Exception e) { - log.error(e.toString()); } try ( @@ -640,7 +642,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } } - if (!complianceConfig.shouldLogWriteMetadataOnly()) { + if (!complianceConfig.shouldLogWriteMetadataOnly() && !complianceConfig.shouldLogDiffsForWrite()) { if (securityIndicesMatcher.test(shardId.getIndexName())) { // current source, normally not null or empty try ( diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 75ce45912a..8fd3589ebe 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,16 +11,11 @@ package org.opensearch.security.authtoken.jwt; -import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; import java.text.ParseException; -import java.util.ArrayList; import java.util.Base64; import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,9 +23,7 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; -import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.authtoken.jwt.claims.JwtClaimsBuilder; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -41,7 +34,6 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.OctetSequenceKey; -import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; @@ -51,21 +43,11 @@ public class JwtVendor { private final JWK signingKey; private final JWSSigner signer; - private final LongSupplier timeProvider; - private final EncryptionDecryptionUtil encryptionDecryptionUtil; - private static final Integer MAX_EXPIRY_SECONDS = 600; - public JwtVendor(final Settings settings, final Optional timeProvider) { + public JwtVendor(Settings settings) { final Tuple tuple = createJwkFromSettings(settings); signingKey = tuple.v1(); signer = tuple.v2(); - - if (isKeyNull(settings, "encryption_key")) { - throw new IllegalArgumentException("encryption_key cannot be null"); - } else { - this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(settings.get("encryption_key")); - } - this.timeProvider = timeProvider.orElse(System::currentTimeMillis); } /* @@ -103,97 +85,11 @@ static Tuple createJwkFromSettings(final Settings settings) { } } - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long requestedExpirySeconds, - final List roles, - final List backendRoles, - final boolean includeBackendRoles - ) throws JOSEException, ParseException { - final long currentTimeMs = timeProvider.getAsLong(); - final Date now = new Date(currentTimeMs); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - final long expirySeconds = Math.min(requestedExpirySeconds, MAX_EXPIRY_SECONDS); - if (expirySeconds <= 0) { - throw new IllegalArgumentException("The expiration time should be a positive integer"); - } - final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); - claimsBuilder.expirationTime(expiryTime); - - if (roles != null) { - final String listOfRoles = String.join(",", roles); - claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); - } else { - throw new IllegalArgumentException("Roles cannot be null"); - } - - if (includeBackendRoles && backendRoles != null) { - final String listOfBackendRoles = String.join(",", backendRoles); - claimsBuilder.claim("br", listOfBackendRoles); - } - - final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = new SignedJWT(header, claimsBuilder.build()); - - // Sign the JWT so it can be serialized - signedJwt.sign(signer); - - if (logger.isDebugEnabled()) { - logger.debug( - "Created JWT: " + signedJwt.serialize() + "\n" + signedJwt.getHeader().toJSONObject() + "\n" + signedJwt.getJWTClaimsSet() - ); - } - - return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); - } - @SuppressWarnings("removal") - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long expiration, - final List clusterPermissions, - final List indexPermissions - ) throws JOSEException, ParseException, IOException { - final long currentTimeMs = timeProvider.getAsLong(); - final Date now = new Date(currentTimeMs); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - final Date expiryTime = new Date(expiration); - claimsBuilder.expirationTime(expiryTime); - - if (clusterPermissions != null) { - final String listOfClusterPermissions = String.join(",", clusterPermissions); - claimsBuilder.claim("cp", encryptString(listOfClusterPermissions)); - } - - if (indexPermissions != null) { - List permissionStrings = new ArrayList<>(); - for (ApiToken.IndexPermission permission : indexPermissions) { - permissionStrings.add(permission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString()); - } - final String listOfIndexPermissions = String.join(",", permissionStrings); - claimsBuilder.claim("ip", encryptString(listOfIndexPermissions)); - } + public ExpiringBearerAuthToken createJwt(JwtClaimsBuilder claimsBuilder, String subject, Date expiryTime, Long expirySeconds) + throws JOSEException, ParseException { final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = AccessController.doPrivileged( (PrivilegedAction) () -> new SignedJWT(header, claimsBuilder.build()) ); @@ -207,16 +103,6 @@ public ExpiringBearerAuthToken createJwt( ); } - return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime); - } - - /* Returns the encrypted string based on encryption settings */ - public String encryptString(final String input) { - return encryptionDecryptionUtil.encrypt(input); - } - - /* Returns the decrypted string based on encryption settings */ - public String decryptString(final String input) { - return encryptionDecryptionUtil.decrypt(input); + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java new file mode 100644 index 0000000000..ebb5552045 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java @@ -0,0 +1,19 @@ +/* + * 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.authtoken.jwt.claims; + +public class ApiJwtClaimsBuilder extends JwtClaimsBuilder { + + public ApiJwtClaimsBuilder() { + super(); + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java new file mode 100644 index 0000000000..2112606b54 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java @@ -0,0 +1,69 @@ +/* + * 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.authtoken.jwt.claims; + +import java.util.Date; + +import com.nimbusds.jwt.JWTClaimsSet; + +public class JwtClaimsBuilder { + private final JWTClaimsSet.Builder builder; + + public JwtClaimsBuilder() { + this.builder = new JWTClaimsSet.Builder(); + } + + public JwtClaimsBuilder issueTime(Date issueTime) { + builder.issueTime(issueTime); + return this; + } + + public JwtClaimsBuilder notBeforeTime(Date notBeforeTime) { + builder.notBeforeTime(notBeforeTime); + return this; + } + + public JwtClaimsBuilder subject(String subject) { + builder.subject(subject); + return this; + } + + public JwtClaimsBuilder issuer(String issuer) { + builder.issuer(issuer); + return this; + } + + public JwtClaimsBuilder audience(String audience) { + builder.audience(audience); + return this; + } + + public JwtClaimsBuilder issuedAt(Date issuedAt) { + builder.issueTime(issuedAt); + return this; + } + + public JwtClaimsBuilder expirationTime(Date expirationTime) { + builder.expirationTime(expirationTime); + return this; + } + + public JwtClaimsBuilder addCustomClaim(String claimName, String value) { + builder.claim(claimName, value); + return this; + } + + public JWTClaimsSet build() { + return builder.build(); + } + +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java new file mode 100644 index 0000000000..22044a165d --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java @@ -0,0 +1,39 @@ +/* + * 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.authtoken.jwt.claims; + +import java.util.List; + +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; + +public class OBOJwtClaimsBuilder extends JwtClaimsBuilder { + private final EncryptionDecryptionUtil encryptionDecryptionUtil; + + public OBOJwtClaimsBuilder(String encryptionKey) { + super(); + this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + } + + public OBOJwtClaimsBuilder addRoles(List roles) { + final String listOfRoles = String.join(",", roles); + this.addCustomClaim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); + return this; + } + + public OBOJwtClaimsBuilder addBackendRoles(Boolean includeBackendRoles, List backendRoles) { + if (includeBackendRoles && backendRoles != null) { + final String listOfBackendRoles = String.join(",", backendRoles); + this.addCustomClaim("br", listOfBackendRoles); + } + return this; + } +} diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java new file mode 100644 index 0000000000..0a8e3466d7 --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,224 @@ +/* + * 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.http; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + + public Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final Boolean apiTokenEnabled; + private final String clusterName; + public static final String API_TOKEN_USER_PREFIX = "apitoken:"; + private final ApiTokenRepository apiTokenRepository; + + @SuppressWarnings("removal") + public ApiTokenAuthenticator(Settings settings, String clusterName, ApiTokenRepository apiTokenRepository) { + String apiTokenEnabledSetting = settings.get("enabled", "true"); + apiTokenEnabled = Boolean.parseBoolean(apiTokenEnabledSetting); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); + } + }); + this.clusterName = clusterName; + this.apiTokenRepository = apiTokenRepository; + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find api token authenticator signing_key"); + } + + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); + } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + return jwtParserBuilder; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) + throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request, context); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + final String subject = claims.getSubject(); + if (subject == null) { + log.error("Api token does not have a subject"); + return null; + } + + // TODO: handle revocation different from deletion? + if (!apiTokenRepository.isValidToken(subject)) { + log.error("Api token is not allowlisted"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this api token does not match the current cluster identifier"); + return null; + } + + return new AuthCredentials(API_TOKEN_USER_PREFIX + subject, List.of(), "").markComplete(); + + } catch (WeakKeyException e) { + log.error("Cannot authenticate api token because of ", e); + return null; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired api token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(SecurityRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "apitoken_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index ca5a17b6f7..f726a8134b 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,10 +11,12 @@ package org.opensearch.security.identity; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.Date; import java.util.Set; +import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,9 +30,10 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; -import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.ConfigConstants; @@ -41,6 +44,8 @@ import joptsimple.internal.Strings; import org.greenrobot.eventbus.Subscribe; +import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; + /** * This class is the Security Plugin's implementation of the TokenManager used by all Identity Plugins. * It handles the issuance of both Service Account Tokens and On Behalf Of tokens. @@ -52,9 +57,11 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; - private JwtVendor oboJwtVendor = null; - private JwtVendor apiTokenJwtVendor = null; + private Settings oboSettings = null; + private Settings apiTokenSettings = null; private ConfigModel configModel = null; + private final LongSupplier timeProvider = System::currentTimeMillis; + private static final Integer OBO_MAX_EXPIRY_SECONDS = 600; public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { this.cs = cs; @@ -69,22 +76,22 @@ public void onConfigModelChanged(final ConfigModel configModel) { @Subscribe public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { - final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); - final Boolean oboEnabled = oboSettings.getAsBoolean("enabled", false); + final Settings oboSettingsFromDcm = dcm.getDynamicOnBehalfOfSettings(); + final Boolean oboEnabled = oboSettingsFromDcm.getAsBoolean("enabled", false); if (oboEnabled) { - oboJwtVendor = createJwtVendor(oboSettings); + oboSettings = oboSettingsFromDcm; } - final Settings apiTokenSettings = dcm.getDynamicApiTokenSettings(); - final Boolean apiTokenEnabled = apiTokenSettings.getAsBoolean("enabled", false); + final Settings apiTokenSettingsFromDcm = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettingsFromDcm.getAsBoolean("enabled", false); if (apiTokenEnabled) { - apiTokenJwtVendor = createJwtVendor(apiTokenSettings); + apiTokenSettings = apiTokenSettingsFromDcm; } } /** For testing */ JwtVendor createJwtVendor(final Settings settings) { try { - return new JwtVendor(settings, Optional.empty()); + return new JwtVendor(settings); } catch (final Exception ex) { logger.error("Unable to create the JwtVendor instance", ex); return null; @@ -92,11 +99,11 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return oboJwtVendor != null && configModel != null; + return oboSettings != null && configModel != null; } public boolean issueApiTokenAllowed() { - return apiTokenJwtVendor != null && configModel != null; + return apiTokenSettings != null && configModel != null; } @Override @@ -125,46 +132,74 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final long expirySeconds = Math.min(claims.getExpiration(), OBO_MAX_EXPIRY_SECONDS); + if (expirySeconds <= 0) { + throw new IllegalArgumentException("The expiration time should be a positive integer"); + } + if (mappedRoles == null) { + throw new IllegalArgumentException("Roles cannot be null"); + } + if (isKeyNull(oboSettings, "encryption_key")) { + throw new IllegalArgumentException("encryption_key cannot be null"); + } + + final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder(oboSettings.get("encryption_key")); + + // Add obo claims + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(user.getName()); + claimsBuilder.audience(claims.getAudience()); + claimsBuilder.notBeforeTime(now); + claimsBuilder.addBackendRoles(false, new ArrayList<>(user.getRoles())); + claimsBuilder.addRoles(new ArrayList<>(mappedRoles)); + + final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); + claimsBuilder.expirationTime(expiryTime); + try { - return oboJwtVendor.createJwt( - cs.getClusterName().value(), - user.getName(), - claims.getAudience(), - claims.getExpiration(), - new ArrayList<>(mappedRoles), - new ArrayList<>(user.getRoles()), - false - ); + return createJwtVendor(oboSettings).createJwt(claimsBuilder, user.getName(), expiryTime, expirySeconds); } catch (final Exception ex) { logger.error("Error creating OnBehalfOfToken for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate OnBehalfOfToken"); } } - public ExpiringBearerAuthToken issueApiToken( - final String name, - final Long expiration, - final List clusterPermissions, - final List indexPermissions - ) { + public ExpiringBearerAuthToken issueApiToken(final String name, final Long expiration) { + if (!issueApiTokenAllowed()) { + throw new OpenSearchSecurityException("Api token generation is not enabled."); + } final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final ApiJwtClaimsBuilder claimsBuilder = new ApiJwtClaimsBuilder(); + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(name); + claimsBuilder.audience(name); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + try { - return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration, clusterPermissions, indexPermissions); + return createJwtVendor(apiTokenSettings).createJwt( + claimsBuilder, + name, + expiryTime, + Duration.between(Instant.now(), expiryTime.toInstant()).getSeconds() + ); } catch (final Exception ex) { logger.error("Error creating Api Token for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate Api Token"); } } - public String encryptToken(final String token) { - return apiTokenJwtVendor.encryptString(token); - } - - public String decryptString(final String input) { - return apiTokenJwtVendor.decryptString(input); - } - @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index eb560ed901..dcb6cded2d 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -13,7 +13,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -35,6 +37,8 @@ import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -141,11 +145,29 @@ public ActionPrivileges( } public PrivilegesEvaluatorResponse hasClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), false); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationContext context, Set actions) { - return cluster.providesAnyPrivilege(context, actions, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesAnyPrivilege((RoleBasedPrivilegesEvaluationContext) context, actions, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, actions, false); + } else { + // Not supported + if (actions.size() == 1) { + return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); + } else { + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } + } } /** @@ -159,7 +181,14 @@ public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationCo * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ public PrivilegesEvaluatorResponse hasExplicitClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesExplicitPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesExplicitPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), true); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } /** @@ -177,44 +206,64 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); - if (response != null) { - return response; - } + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); + if (response != null) { + return response; + } - if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { - // This is necessary for requests which operate on remote indices. - // Access control for the remote indices will be performed on the remote cluster. - log.debug("No local indices; grant the request"); - return PrivilegesEvaluatorResponse.ok(); - } + if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { + // This is necessary for requests which operate on remote indices. + // Access control for the remote indices will be performed on the remote cluster. + log.debug("No local indices; grant the request"); + return PrivilegesEvaluatorResponse.ok(); + } - // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart - // what's the action and what's the index in the generic parameters of CheckTable. - CheckTable checkTable = CheckTable.create( - resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), - actions - ); + // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart + // what's the action and what's the index in the generic parameters of CheckTable. + CheckTable checkTable = CheckTable.create( + resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), + actions + ); - StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); - PrivilegesEvaluatorResponse resultFromStatefulIndex = null; + StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); + PrivilegesEvaluatorResponse resultFromStatefulIndex = null; - Map indexMetadata = this.indexMetadataSupplier.get(); + Map indexMetadata = this.indexMetadataSupplier.get(); - if (statefulIndex != null) { - resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); + if (statefulIndex != null) { + resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); - if (resultFromStatefulIndex != null) { - // If we get a result from statefulIndex, we are done. - return resultFromStatefulIndex; + if (resultFromStatefulIndex != null) { + // If we get a result from statefulIndex, we are done. + return resultFromStatefulIndex; + } + + // Otherwise, we need to carry on checking privileges using the non-stateful object. + // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. + // We can carry on using this as an intermediate result and further complete checkTable below. } + return this.index.providesPrivilege( + (RoleBasedPrivilegesEvaluationContext) context, + actions, + resolvedIndices, + checkTable, + indexMetadata + ); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + Map indexMetadata = this.indexMetadataSupplier.get(); + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + indexMetadata, + false + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("No explicit privileges have been provided for the referenced indices."); - // Otherwise, we need to carry on checking privileges using the non-stateful object. - // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. - // We can carry on using this as an intermediate result and further complete checkTable below. } - - return this.index.providesPrivilege(context, actions, resolvedIndices, checkTable, indexMetadata); } /** @@ -229,8 +278,21 @@ public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); - return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); + return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + this.indexMetadataSupplier.get(), + true + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } } /** @@ -322,6 +384,8 @@ static class ClusterPrivileges { private final ImmutableSet wellKnownClusterActions; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed cluster privileges based on the given parameters. *

@@ -409,6 +473,7 @@ static class ClusterPrivileges { this.rolesToActionMatcher = rolesToActionMatcher.build(); this.usersToActionMatcher = usersToActionMatcher.build(); this.wellKnownClusterActions = wellKnownClusterActions; + this.actionGroups = actionGroups; } /** @@ -416,7 +481,7 @@ static class ClusterPrivileges { * provided roles. Returns a PrivilegesEvaluatorResponse with allowed=true if privileges are available. * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ - PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext context, String action, Set roles) { + PrivilegesEvaluatorResponse providesPrivilege(RoleBasedPrivilegesEvaluationContext context, String action, Set roles) { // 1: Check roles with wildcards if (CollectionUtils.containsAny(roles, this.rolesWithWildcardPermissions)) { @@ -452,6 +517,54 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex return PrivilegesEvaluatorResponse.insufficient(action); } + /** + * Evaluates cluster privileges for api tokens. It does so by checking exact match, regex match, * match, and action group match in a non-optimized, naive way. + * First it expands all action groups to get all the actions and patterns of actions. Then it checks * if not an explicit check, then for exact match, then for pattern match. + */ + PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( + PermissionBasedPrivilegesEvaluationContext context, + Set actions, + Boolean explicit + ) { + Permissions permissions = context.getPermissions(); + Set resolvedClusterPermissions = actionGroups.resolve(permissions.getClusterPerm()); + + // Check for wildcard permission + if (!explicit) { + if (resolvedClusterPermissions.contains("*")) { + return PrivilegesEvaluatorResponse.ok(); + } + } + + // Check for exact match + if (!Collections.disjoint(resolvedClusterPermissions, actions)) { + return PrivilegesEvaluatorResponse.ok(); + } + + // Check for pattern matches (like "cluster:*") + for (String permission : resolvedClusterPermissions) { + // skip pure *, which was evaluated above + if (!"*".equals(permission)) { + // Skip exact matches as we already checked those + if (!permission.contains("*")) { + continue; + } + + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + for (String action : actions) { + if (permissionMatcher.test(action)) { + return PrivilegesEvaluatorResponse.ok(); + } + } + } + } + if (actions.size() == 1) { + return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); + } else { + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } + } + /** * Checks whether this instance provides explicit privileges for the combination of the provided action and the * provided roles. @@ -462,7 +575,11 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex * Returns a PrivilegesEvaluatorResponse with allowed=true if privileges are available. * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ - PrivilegesEvaluatorResponse providesExplicitPrivilege(PrivilegesEvaluationContext context, String action, Set roles) { + PrivilegesEvaluatorResponse providesExplicitPrivilege( + RoleBasedPrivilegesEvaluationContext context, + String action, + Set roles + ) { // 1: Check well-known actions - this should cover most cases ImmutableCompactSubSet rolesWithPrivileges = this.actionToRoles.get(action); @@ -490,7 +607,11 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege(PrivilegesEvaluationContex * provided roles. Returns a PrivilegesEvaluatorResponse with allowed=true if privileges are available. * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ - PrivilegesEvaluatorResponse providesAnyPrivilege(PrivilegesEvaluationContext context, Set actions, Set roles) { + PrivilegesEvaluatorResponse providesAnyPrivilege( + RoleBasedPrivilegesEvaluationContext context, + Set actions, + Set roles + ) { // 1: Check roles with wildcards if (CollectionUtils.containsAny(roles, this.rolesWithWildcardPermissions)) { return PrivilegesEvaluatorResponse.ok(); @@ -591,6 +712,8 @@ static class IndexPrivileges { */ private final ImmutableMap> rolesToExplicitActionToIndexPattern; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed index privileges based on the given parameters. *

@@ -728,6 +851,7 @@ static class IndexPrivileges { this.wellKnownIndexActions = wellKnownIndexActions; this.explicitlyRequiredIndexActions = explicitlyRequiredIndexActions; + this.actionGroups = actionGroups; } /** @@ -747,7 +871,7 @@ static class IndexPrivileges { * checkTable instance as checked. */ PrivilegesEvaluatorResponse providesPrivilege( - PrivilegesEvaluationContext context, + RoleBasedPrivilegesEvaluationContext context, Set actions, IndexResolverReplacer.Resolved resolvedIndices, CheckTable checkTable, @@ -901,11 +1025,70 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege( } } } - return PrivilegesEvaluatorResponse.insufficient(checkTable) .reason("No explicit privileges have been provided for the referenced indices.") .evaluationExceptions(exceptions); } + + PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( + PermissionBasedPrivilegesEvaluationContext context, + IndexResolverReplacer.Resolved resolvedIndices, + Set actions, + Map indexMetadata, + Boolean explicit + ) { + Permissions permissions = context.getPermissions(); + List indexPermissions = permissions.getIndexPermission(); + + for (String concreteIndex : resolvedIndices.getAllIndices()) { + boolean indexHasAllPermissions = false; + + // Check each index permission + for (ApiToken.IndexPermission indexPermission : indexPermissions) { + // First check if this permission applies to this index + IndexPattern indexPattern = IndexPattern.from(indexPermission.getIndexPatterns()); + boolean indexMatched = false; + try { + indexMatched = indexPattern.matches(concreteIndex, context, indexMetadata); + } catch (PrivilegesEvaluationException e) { + // We can ignore these errors, as this max leads to fewer privileges than available + log.error("Error while evaluating index pattern. Ignoring entry"); + } + if (!indexMatched) { + continue; + } + + // Index matched, now check if this permission covers all actions + Set remainingActions = new HashSet<>(actions); + ImmutableSet resolvedIndexPermissions = actionGroups.resolve(indexPermission.getAllowedActions()); + + for (String permission : resolvedIndexPermissions) { + // Skip global wildcard if explicit is true + if (explicit && permission.equals("*")) { + continue; + } + + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + remainingActions.removeIf(action -> permissionMatcher.test(action)); + + if (remainingActions.isEmpty()) { + indexHasAllPermissions = true; + break; + } + } + + if (indexHasAllPermissions) { + break; // Found a permission that covers all actions for this index + } + } + + if (!indexHasAllPermissions) { + return PrivilegesEvaluatorResponse.insufficient("Insufficient permissions for the index" + concreteIndex); + } + } + // If we get here, all indices had sufficient permissions + return PrivilegesEvaluatorResponse.ok(); + } } /** diff --git a/src/main/java/org/opensearch/security/privileges/PermissionBasedPrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PermissionBasedPrivilegesEvaluationContext.java new file mode 100644 index 0000000000..9b3333cc47 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/PermissionBasedPrivilegesEvaluationContext.java @@ -0,0 +1,71 @@ +/* + * 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.privileges; + +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableSet; + +import org.opensearch.action.ActionRequest; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.security.action.apitokens.Permissions; +import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.user.User; +import org.opensearch.tasks.Task; + +public class PermissionBasedPrivilegesEvaluationContext extends PrivilegesEvaluationContext { + private final Permissions permissions; + + public PermissionBasedPrivilegesEvaluationContext( + User user, + String action, + ActionRequest request, + Task task, + IndexResolverReplacer indexResolverReplacer, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier clusterStateSupplier, + Permissions permissions + ) { + super(user, action, request, task, indexResolverReplacer, indexNameExpressionResolver, clusterStateSupplier); + this.permissions = permissions; + } + + @Override + public String toString() { + return "PermissionBasedPrivilegesEvaluationContext{" + + "user=" + + getUser() + + ", action='" + + getAction() + + '\'' + + ", request=" + + getRequest() + + ", resolvedRequest=" + + getResolvedRequest() + + ", permissions=" + + permissions + + '}'; + } + + public Permissions getPermissions() { + return permissions; + } + + @Override + public ImmutableSet getMappedRoles() { + return ImmutableSet.of(); + } + + @Override + void setMappedRoles(ImmutableSet roles) {} +} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index f7e5d6de7d..cc6a006ffc 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -25,23 +25,14 @@ import org.opensearch.security.user.User; import org.opensearch.tasks.Task; -/** - * Request-scoped context information for privilege evaluation. - *

- * This class carries metadata about the request and provides caching facilities for data which might need to be - * evaluated several times per request. - *

- * As this class is request-scoped, it is only used by a single thread. Thus, no thread synchronization mechanisms - * are necessary. - */ -public class PrivilegesEvaluationContext { +public abstract class PrivilegesEvaluationContext { + private final User user; private final String action; private final ActionRequest request; private IndexResolverReplacer.Resolved resolvedRequest; private Map indicesLookup; private final Task task; - private ImmutableSet mappedRoles; private final IndexResolverReplacer indexResolverReplacer; private final IndexNameExpressionResolver indexNameExpressionResolver; private final Supplier clusterStateSupplier; @@ -53,9 +44,8 @@ public class PrivilegesEvaluationContext { */ private final Map renderedPatternTemplateCache = new HashMap<>(); - public PrivilegesEvaluationContext( + PrivilegesEvaluationContext( User user, - ImmutableSet mappedRoles, String action, ActionRequest request, Task task, @@ -64,13 +54,12 @@ public PrivilegesEvaluationContext( Supplier clusterStateSupplier ) { this.user = user; - this.mappedRoles = mappedRoles; this.action = action; this.request = request; - this.clusterStateSupplier = clusterStateSupplier; + this.task = task; this.indexResolverReplacer = indexResolverReplacer; this.indexNameExpressionResolver = indexNameExpressionResolver; - this.task = task; + this.clusterStateSupplier = clusterStateSupplier; } public User getUser() { @@ -125,22 +114,6 @@ public Task getTask() { return task; } - public ImmutableSet getMappedRoles() { - return mappedRoles; - } - - /** - * Note: Ideally, mappedRoles would be an unmodifiable attribute. PrivilegesEvaluator however contains logic - * related to OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION which first validates roles and afterwards modifies - * them again. Thus, we need to be able to set this attribute. - * - * However, this method should be only used for this one particular phase. Normally, all roles should be determined - * upfront and stay constant during the whole privilege evaluation process. - */ - void setMappedRoles(ImmutableSet mappedRoles) { - this.mappedRoles = mappedRoles; - } - public Supplier getClusterStateSupplier() { return clusterStateSupplier; } @@ -156,20 +129,7 @@ public IndexNameExpressionResolver getIndexNameExpressionResolver() { return indexNameExpressionResolver; } - @Override - public String toString() { - return "PrivilegesEvaluationContext{" - + "user=" - + user - + ", action='" - + action - + '\'' - + ", request=" - + request - + ", resolvedRequest=" - + resolvedRequest - + ", mappedRoles=" - + mappedRoles - + '}'; - } + public abstract ImmutableSet getMappedRoles(); + + abstract void setMappedRoles(ImmutableSet roles); } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 158a5d0a48..1368e1cecc 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -87,6 +87,7 @@ import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.index.reindex.ReindexAction; import org.opensearch.script.mustache.RenderSearchTemplateAction; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.configuration.ConfigurationRepository; @@ -156,6 +157,7 @@ public class PrivilegesEvaluator { private final Settings settings; private final Map> pluginToClusterActions; private final AtomicReference actionPrivileges = new AtomicReference<>(); + private ApiTokenRepository apiTokenRepository; public PrivilegesEvaluator( final ClusterService clusterService, @@ -169,7 +171,8 @@ public PrivilegesEvaluator( final PrivilegesInterceptor privilegesInterceptor, final ClusterInfoHolder clusterInfoHolder, final IndexResolverReplacer irr, - NamedXContentRegistry namedXContentRegistry + NamedXContentRegistry namedXContentRegistry, + ApiTokenRepository apiTokenRepository ) { super(); @@ -221,6 +224,8 @@ public PrivilegesEvaluator( }); } + this.apiTokenRepository = apiTokenRepository; + } void updateConfiguration( @@ -303,8 +308,20 @@ public PrivilegesEvaluationContext createContext( TransportAddress caller = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); ImmutableSet mappedRoles = ImmutableSet.copyOf((injectedRoles == null) ? mapRoles(user, caller) : injectedRoles); + if (user.getName().startsWith("apitoken:")) { + return new PermissionBasedPrivilegesEvaluationContext( + user, + action0, + request, + task, + irr, + resolver, + clusterStateSupplier, + apiTokenRepository.getApiTokenPermissionsForUser(user) + ); + } - return new PrivilegesEvaluationContext(user, mappedRoles, action0, request, task, irr, resolver, clusterStateSupplier); + return new RoleBasedPrivilegesEvaluationContext(user, mappedRoles, action0, request, task, irr, resolver, clusterStateSupplier); } public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { diff --git a/src/main/java/org/opensearch/security/privileges/RoleBasedPrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/RoleBasedPrivilegesEvaluationContext.java new file mode 100644 index 0000000000..ff39053d0c --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/RoleBasedPrivilegesEvaluationContext.java @@ -0,0 +1,84 @@ +/* + * 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.privileges; + +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableSet; + +import org.opensearch.action.ActionRequest; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.user.User; +import org.opensearch.tasks.Task; + +/** + * Request-scoped context information for privilege evaluation. + *

+ * This class carries metadata about the request and provides caching facilities for data which might need to be + * evaluated several times per request. + *

+ * As this class is request-scoped, it is only used by a single thread. Thus, no thread synchronization mechanisms + * are necessary. + */ +public class RoleBasedPrivilegesEvaluationContext extends PrivilegesEvaluationContext { + private ImmutableSet mappedRoles; + + public RoleBasedPrivilegesEvaluationContext( + User user, + ImmutableSet mappedRoles, + String action, + ActionRequest request, + Task task, + IndexResolverReplacer indexResolverReplacer, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier clusterStateSupplier + ) { + super(user, action, request, task, indexResolverReplacer, indexNameExpressionResolver, clusterStateSupplier); + this.mappedRoles = mappedRoles; + } + + @Override + public ImmutableSet getMappedRoles() { + return mappedRoles; + } + + /** + * Note: Ideally, mappedRoles would be an unmodifiable attribute. PrivilegesEvaluator however contains logic + * related to OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION which first validates roles and afterwards modifies + * them again. Thus, we need to be able to set this attribute. + * + * However, this method should be only used for this one particular phase. Normally, all roles should be determined + * upfront and stay constant during the whole privilege evaluation process. + */ + @Override + void setMappedRoles(ImmutableSet mappedRoles) { + this.mappedRoles = mappedRoles; + } + + @Override + public String toString() { + return "RoleBasedPrivilegesEvaluationContext{" + + "user=" + + getUser() + + ", action='" + + getAction() + + '\'' + + ", request=" + + getRequest() + + ", resolvedRequest=" + + getResolvedRequest() + + ", mappedRoles=" + + mappedRoles + + '}'; + } +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 10402f7b56..1f86f459c8 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -43,6 +43,7 @@ import org.opensearch.client.Client; import org.opensearch.common.settings.Settings; import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.config.AuditConfig; import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; @@ -127,6 +128,7 @@ public final static SecurityDynamicConfiguration addStatics(SecurityDynam private final Path configPath; private final InternalAuthenticationBackend iab; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; SecurityDynamicConfiguration config; @@ -137,7 +139,8 @@ public DynamicConfigFactory( Client client, ThreadPool threadPool, ClusterInfoHolder cih, - PasswordHasher passwordHasher + PasswordHasher passwordHasher, + ApiTokenRepository apiTokenRepository ) { super(); this.cr = cr; @@ -145,6 +148,7 @@ public DynamicConfigFactory( this.configPath = configPath; this.cih = cih; this.iab = new InternalAuthenticationBackend(passwordHasher); + this.apiTokenRepository = apiTokenRepository; if (opensearchSettings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES, true)) { try { @@ -269,7 +273,7 @@ public void onChange(ConfigurationMap typeToConfig) { ); // rebuild v7 Models - dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); + dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih, apiTokenRepository); ium = new InternalUsersModelV7(internalusers, roles, rolesmapping); cm = new ConfigModelV7(roles, rolesmapping, actionGroups, tenants, dcm, opensearchSettings); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 9c90e2341f..55271e960b 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -49,6 +49,7 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auth.AuthDomain; import org.opensearch.security.auth.AuthFailureListener; import org.opensearch.security.auth.AuthenticationBackend; @@ -59,6 +60,7 @@ import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.v7.ConfigV7; @@ -85,13 +87,15 @@ public class DynamicConfigModelV7 extends DynamicConfigModel { private List> ipClientBlockRegistries; private Multimap> authBackendClientBlockRegistries; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; public DynamicConfigModelV7( ConfigV7 config, Settings opensearchSettings, Path configPath, InternalAuthenticationBackend iab, - ClusterInfoHolder cih + ClusterInfoHolder cih, + ApiTokenRepository apiTokenRepository ) { super(); this.config = config; @@ -99,6 +103,7 @@ public DynamicConfigModelV7( this.configPath = configPath; this.iab = iab; this.cih = cih; + this.apiTokenRepository = apiTokenRepository; buildAAA(); } @@ -377,6 +382,23 @@ private void buildAAA() { } } + /* + * If the Api token authentication is configured: + * Add the ApiToken authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when ApiToken authentication failed + * order: -2 - prioritize the Api token authentication when it gets enabled + */ + Settings apiTokenSettings = getDynamicApiTokenSettings(); + if (!isKeyNull(apiTokenSettings, "signing_key")) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName(), apiTokenRepository), + false, + -2 + ); + restAuthDomains0.add(_ad); + } + /* * If the OnBehalfOf (OBO) authentication is configured: * Add the OBO authbackend in to the auth domains diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 6555c0838d..d960a9e9bd 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -503,8 +503,6 @@ public static class ApiTokenSettings { private Boolean enabled = Boolean.FALSE; @JsonProperty("signing_key") private String signingKey; - @JsonProperty("encryption_key") - private String encryptionKey; @JsonIgnore public String configAsJson() { @@ -519,8 +517,8 @@ public Boolean getEnabled() { return enabled; } - public void setEnabled(Boolean oboEnabled) { - this.enabled = oboEnabled; + public void setEnabled(Boolean apiTokensEnabled) { + this.enabled = apiTokensEnabled; } public String getSigningKey() { @@ -531,17 +529,9 @@ public void setSigningKey(String signingKey) { this.signingKey = signingKey; } - public String getEncryptionKey() { - return encryptionKey; - } - - public void setEncryptionKey(String encryptionKey) { - this.encryptionKey = encryptionKey; - } - @Override public String toString() { - return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + "]"; } } diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 4683075f1d..32a70a468f 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -68,6 +68,10 @@ public static OpenSearchException invalidUsageOfOBOTokenException() { return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); } + public static OpenSearchException invalidUsageOfApiTokenException() { + return new OpenSearchException("Api Tokens are not allowed to be used for accessing this endpoint."); + } + public static OpenSearchException createJwkCreationException() { return new OpenSearchException("An error occurred during the creation of Jwk."); } diff --git a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java index 3884bf75fe..caccb91407 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -20,6 +20,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String API_TOKEN_SUFFIX = "api/apitokens"; public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { if (suffix == null) { @@ -28,6 +29,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques switch (suffix) { case ON_BEHALF_OF_SUFFIX: return request.method() == POST; + case API_TOKEN_SUFFIX: + // Don't want to allow any api token access + return true; case ACCOUNT_SUFFIX: return request.method() == PUT; default: diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 483fe7c9d7..e7193710e1 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -23,10 +23,11 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; public class ApiTokenActionTest { - private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null); + private final ApiTokenAction apiTokenAction = new ApiTokenAction(mock(ApiTokenRepository.class)); @Test public void testCreateIndexPermission() { diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..c24c632fa0 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,197 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; + +import org.apache.logging.log4j.Logger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.security.user.AuthCredentials; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenAuthenticatorTest { + + private ApiTokenAuthenticator authenticator; + @Mock + private Logger log; + @Mock + private ApiTokenRepository apiTokenRepository; + + private ThreadContext threadcontext; + private final String signingKey = Base64.getEncoder() + .encodeToString("jwt signing key long enough for secure api token authentication testing".getBytes(StandardCharsets.UTF_8)); + private final String tokenName = "test-token"; + + @Before + public void setUp() { + Settings settings = Settings.builder().put("enabled", "true").put("signing_key", signingKey).build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + when(log.isDebugEnabled()).thenReturn(true); + threadcontext = new ThreadContext(Settings.EMPTY); + } + + @Test + public void testAuthenticationFailsWhenJtiNotInCache() { + String testJti = "test-jti-not-in-cache"; + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + AuthCredentials credentials = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is not in allowlist cache", credentials); + } + + @Test + public void testExtractCredentialsPassWhenJtiInCache() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNotNull("Should not be null when JTI is in allowlist cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().minus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is expired", ac); + verify(log).debug(eq("Invalid or expired api token."), any(ExpiredJwtException.class)); + + } + + @Test + public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { + String token = Jwts.builder() + .setIssuer("not-opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when issuer does not match cluster", ac); + verify(log).error(eq("The issuer of this api token does not match the current cluster identifier")); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + 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 token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + + Settings settings = Settings.builder() + .put("enabled", "false") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when api tokens auth is not enabled", ac); + verify(log).error(eq("Api token authentication is disabled")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java index 7e03c14851..9b3b8638e2 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -192,7 +192,6 @@ public void testIndexTokenStoresTokenPayload() { ); ApiToken token = new ApiToken( "test-token-description", - "test-token-jti", clusterPermissions, indexPermissions, Instant.now(), @@ -231,7 +230,6 @@ public void testIndexTokenStoresTokenPayload() { String source = capturedRequest.source().utf8ToString(); assertThat(source, containsString("test-token-description")); assertThat(source, containsString("cluster:admin/something")); - assertThat(source, containsString("test-token-jti")); assertThat(source, containsString("test-index-*")); } @@ -245,7 +243,6 @@ public void testGetTokenPayloads() throws IOException { // First token ApiToken token1 = new ApiToken( "token1-description", - "token1-jti", Arrays.asList("cluster:admin/something"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index1-*"), @@ -258,7 +255,6 @@ public void testGetTokenPayloads() throws IOException { // Second token ApiToken token2 = new ApiToken( "token2-description", - "token2-jti", Arrays.asList("cluster:admin/other"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index2-*"), diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java index 03a2e2c30e..89f8b950cd 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -11,6 +11,7 @@ package org.opensearch.security.action.apitokens; +import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -18,15 +19,22 @@ import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.doThrow; @@ -34,13 +42,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ApiTokenRepositoryTest { @Mock private SecurityTokenManager securityTokenManager; - @Mock private ApiTokenIndexHandler apiTokenIndexHandler; - private ApiTokenRepository repository; @Before @@ -59,10 +66,33 @@ public void testDeleteApiToken() throws ApiTokenException { verify(apiTokenIndexHandler).deleteToken(tokenName); } + @Test + public void testGetApiTokenPermissionsForUser() throws ApiTokenException { + User derek = new User("derek"); + User apiTokenNotExists = new User("apitoken:notexists"); + User apiTokenExists = new User("apitoken:exists"); + repository.getJtis() + .put("exists", new Permissions(List.of("cluster_all"), List.of(new ApiToken.IndexPermission(List.of("*"), List.of("*"))))); + + Permissions permissionsForDerek = repository.getApiTokenPermissionsForUser(derek); + assertEquals(List.of(), permissionsForDerek.getClusterPerm()); + assertEquals(List.of(), permissionsForDerek.getIndexPermission()); + + Permissions permissionsForApiTokenNotExists = repository.getApiTokenPermissionsForUser(apiTokenNotExists); + assertEquals(List.of(), permissionsForApiTokenNotExists.getClusterPerm()); + assertEquals(List.of(), permissionsForApiTokenNotExists.getIndexPermission()); + + Permissions permissionsForApiTokenExists = repository.getApiTokenPermissionsForUser(apiTokenExists); + assertEquals(List.of("cluster_all"), permissionsForApiTokenExists.getClusterPerm()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getAllowedActions()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getIndexPatterns()); + + } + @Test public void testGetApiTokens() throws IndexNotFoundException { Map expectedTokens = new HashMap<>(); - expectedTokens.put("token1", new ApiToken("token1", "token1-jti", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); + expectedTokens.put("token1", new ApiToken("token1", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(expectedTokens); Map result = repository.getApiTokens(); @@ -81,23 +111,20 @@ public void testCreateApiToken() { Long expiration = 3600L; String completeToken = "complete-token"; - String encryptedToken = "encrypted-token"; ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); when(bearerToken.getCompleteToken()).thenReturn(completeToken); - when(securityTokenManager.issueApiToken(any(), any(), any(), any())).thenReturn(bearerToken); - when(securityTokenManager.encryptToken(completeToken)).thenReturn(encryptedToken); + when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); - verify(securityTokenManager).issueApiToken(any(), any(), any(), any()); - verify(securityTokenManager).encryptToken(completeToken); + verify(securityTokenManager).issueApiToken(any(), any()); verify(apiTokenIndexHandler).indexTokenMetadata( argThat( token -> token.getName().equals(tokenName) - && token.getJti().equals(encryptedToken) && token.getClusterPermissions().equals(clusterPermissions) && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) ) ); assertThat(result, equalTo(completeToken)); @@ -118,4 +145,40 @@ public void testDeleteApiTokenThrowsApiTokenException() throws ApiTokenException repository.deleteApiToken(tokenName); } + + @Test + public void testJtisOperations() { + String jti = "testJti"; + Permissions permissions = new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of()))); + + repository.getJtis().put(jti, permissions); + assertEquals("Should retrieve correct permissions", permissions, repository.getJtis().get(jti)); + + repository.getJtis().remove(jti); + assertNull("Should return null after removal", repository.getJtis().get(jti)); + } + + @Test + public void testClearJtis() { + repository.getJtis().put("testJti", new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + repository.reloadApiTokensFromIndex(); + + assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty()); + } + + @Test + public void testReloadApiTokensFromIndexAndParse() throws IOException { + when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(Map.of("test", new ApiToken("test", List.of("cluster:monitor"), List.of(), Long.MAX_VALUE))); + + // Execute the reload + repository.reloadApiTokensFromIndex(); + + // Verify the cache was updated + assertFalse("Jtis should not be empty after reload", repository.getJtis().isEmpty()); + assertEquals("Should have one JTI entry", 1, repository.getJtis().size()); + assertTrue("Should contain testJti", repository.getJtis().containsKey("test")); + // Verify extraction works + assertEquals("Should have one cluster action", List.of("cluster:monitor"), repository.getJtis().get("test").getClusterPerm()); + assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndexPermission()); + } } diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index e0026155de..2ab7b9da8e 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -27,6 +27,17 @@ public class AuthTokenUtilsTest { + @Test + public void testIsAccessToRestrictedEndpointsForApiToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/apitokens") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + @Test public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 48aae6f9b8..6112f2794f 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -11,11 +11,9 @@ package org.opensearch.security.authtoken.jwt; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; -import java.util.Optional; import java.util.function.LongSupplier; import com.google.common.io.BaseEncoding; @@ -31,26 +29,19 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; -import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.xcontent.DeprecationHandler; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.support.ConfigConstants; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jwt.SignedJWT; -import joptsimple.internal.Strings; import org.mockito.ArgumentCaptor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; import static org.junit.Assert.assertThrows; @@ -75,7 +66,7 @@ public void testCreateJwkFromSettings() { final Tuple jwk = JwtVendor.createJwkFromSettings(settings); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); assertThat(jwk.v1().getKeyUse().toString(), is("sig")); - Assert.assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); + assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); } @Test @@ -109,8 +100,21 @@ public void testCreateJwtWithRoles() throws Exception { String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor OBOJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(false, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -146,8 +150,21 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) // CS-ENFORCE-SINGLE .build(); - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -163,87 +180,6 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("er").toString()), equalTo(expectedRoles)); } - @Test - public void testCreateJwtWithNegativeExpiry() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("admin"); - Integer expirySeconds = -300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); - } - - @Test - public void testCreateJwtWithExceededExpiry() throws Exception { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("IT", "HR"); - List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 900_000; - LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. - assertThat(authToken.getExpiresInSeconds(), not(equalTo(expirySeconds))); - assertThat(authToken.getExpiresInSeconds(), equalTo(600L)); - } - - @Test - public void testCreateJwtWithBadEncryptionKey() { - final String issuer = "cluster_0"; - final String subject = "admin"; - final String audience = "audience_0"; - final List roles = List.of("admin"); - final Integer expirySeconds = 300; - - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - new JwtVendor(settings, Optional.empty()).createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); - } - - @Test - public void testCreateJwtWithBadRoles() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = null; - Integer expirySeconds = 300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); - } - @Test public void testCreateJwtLogsCorrectly() throws Exception { mockAppender = mock(Appender.class); @@ -264,11 +200,22 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String audience = "audience_0"; final List roles = List.of("IT", "HR"); final List backendRoles = List.of("Sales", "Support"); - final int expirySeconds = 300; - - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + int expirySeconds = 300; - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); verify(mockAppender, times(1)).append(logEventCaptor.capture()); @@ -281,112 +228,45 @@ public void testCreateJwtLogsCorrectly() throws Exception { } @Test - public void testCreateJwtForApiTokenSuccess() throws Exception { - final String issuer = "cluster_0"; - final String subject = "test-token"; - final String audience = "test-token"; - final List clusterPermissions = List.of("cluster:admin/*"); - ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission(List.of("*"), List.of("read")); - final List indexPermissions = List.of(indexPermission); - final String expectedClusterPermissions = "cluster:admin/*"; - final String expectedIndexPermissions = indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) - .toString(); + public void testCreateApiTokenJwtSuccess() throws Exception { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + int expirySeconds = 300; + // 2023 oct 4, 10:00:00 AM GMT + LongSupplier currentTime = () -> 1696413600000L; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = "1234567890123456"; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt( - issuer, - subject, - audience, - Long.MAX_VALUE, - clusterPermissions, - indexPermissions + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor apiTokenJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = apiTokenJwtVendor.createJwt( + new ApiJwtClaimsBuilder().issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo(issuer)); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo(subject)); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[" + audience + "]")); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iat"), is(notNullValue())); - // Allow for millisecond to second conversion flexibility - assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime() / 1000, equalTo(Long.MAX_VALUE / 1000)); - - EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); - assertThat( - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), - equalTo(expectedClusterPermissions) - ); - assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); - - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) - ); - ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); - - // Index permission deserialization works as expected - assertThat(indexPermission1.getIndexPatterns(), equalTo(indexPermission.getIndexPatterns())); - assertThat(indexPermission1.getAllowedActions(), equalTo(indexPermission.getAllowedActions())); - } - - @Test - public void testEncryptJwtCorrectly() { - String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); - String token = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJkZXJlayI6ImlzIGF3ZXNvbWUifQ.aPp9mSaBRBUzMJ8V_MYWUs8UoGYnJDNVriu3B9MRJpPNZtOhnIfATE0Ghmms2bGRNw9rmyRn1VIDQRmxSOTu3w"; - String expectedEncryptedToken = - "k3JQNRXR57Y4V4W1LNkpEP7FTJZos7fySJDJDGuBQXe7pi9aiEIGJ7JqjezssGRZ1AZGD/QTPQ0jjaV+rEICxBO9oyfTYWIoDdnAg5LijqPAzaULp48hi+/dqXXAAhi1zIlCSjqTDoZMTyjFxq4aRlPLjjQFuVxR3gIDMNnAUnvmFu5xh5AiVeKa1dwGy5X34Ou2i9pnQzmEDJDnf6mh7w2ODkDThJGh8JUlsUlfZEq6NwVN1XNyOr2IhPd3IZYUMgN3vWHyfjs6uwQNyHKHHcxIj4P8bJXLIGxJy3+LV5Y="; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - LongSupplier currentTime = () -> (long) 100; - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - assertThat(jwtVendor.encryptString(token), equalTo(expectedEncryptedToken)); - } - - @Test - public void testEncryptDecryptClusterIndexPermissionsCorrectly() throws IOException { - String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); - String clusterPermissions = "cluster:admin/*,cluster:*"; - String encryptedClusterPermissions = "P+KGUkpANJHzHGKVSqJhIyHOKS+JCLOanxCOBWSgZNk="; - // "{\"index_pattern\":[\"*\"],\"allowed_actions\":[\"read\"]},{\"index_pattern\":[\".*\"],\"allowed_actions\":[\"write\"]}" - String indexPermissions = Strings.join( - List.of( - new ApiToken.IndexPermission(List.of("*"), List.of("read")).toXContent( - XContentFactory.jsonBuilder(), - ToXContent.EMPTY_PARAMS - ).toString(), - new ApiToken.IndexPermission(List.of(".*"), List.of("write")).toXContent( - XContentFactory.jsonBuilder(), - ToXContent.EMPTY_PARAMS - ).toString() - ), - "," - ); - String encryptedIndexPermissions = - "Y9ssHcl6spHC2/zy+L1P0y8e2+T+jGgXcP02DWGeTMk/3KiI4Ik0Df7oXMf9l/Ba0emk9LClnHsJi8iFwRh7ii1Pxb3CTHS/d+p7a3bA6rtJjgOjGlbjdWTdj4+87uBJynsR5CAlUMLeTrjbPe/nWw=="; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - LongSupplier currentTime = () -> (long) 100; - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - - // encrypt decrypt cluster permissions - assertThat(jwtVendor.encryptString(clusterPermissions), equalTo(encryptedClusterPermissions)); - assertThat(jwtVendor.decryptString(encryptedClusterPermissions), equalTo(clusterPermissions)); - - // encrypt decrypt index permissions - assertThat(jwtVendor.encryptString(indexPermissions), equalTo(encryptedIndexPermissions)); - assertThat(jwtVendor.decryptString(encryptedIndexPermissions), equalTo(indexPermissions)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[audience_0]")); + // 2023 oct 4, 10:00:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("iat")).getTime(), is(1696413600000L)); + // 2023 oct 4, 10:05:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime(), is(1696413900000L)); } @Test - public void testKeyTooShortThrowsException() { - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + public void testKeyTooShortForApiTokenThrowsException() { String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); - Settings settings = Settings.builder().put("signing_key", tooShortKey).put("encryption_key", claimsEncryptionKey).build(); - final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings, Optional.empty()); }); + Settings settings = Settings.builder().put("signing_key", tooShortKey).build(); + final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings); }); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index 7ecbb6da34..90531dfdb8 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -12,9 +12,11 @@ package org.opensearch.security.identity; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; +import com.google.common.io.BaseEncoding; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -37,16 +39,15 @@ import org.opensearch.security.user.UserService; import org.opensearch.threadpool.ThreadPool; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -76,11 +77,13 @@ public void setup() { @After public void after() { - verifyNoMoreInteractions(cs); - verifyNoMoreInteractions(threadPool); verifyNoMoreInteractions(userService); } + final static String signingKey = + "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; + final static String signingKeyB64Encoded = BaseEncoding.base64().encode(signingKey.getBytes(StandardCharsets.UTF_8)); + @Test public void onConfigModelChanged_oboNotSupported() { final ConfigModel configModel = mock(ConfigModel.class); @@ -94,7 +97,7 @@ public void onConfigModelChanged_oboNotSupported() { @Test public void onDynamicConfigModelChanged_JwtVendorEnabled() { final ConfigModel configModel = mock(ConfigModel.class); - final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(); + final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(true); tokenManager.onConfigModelChanged(configModel); @@ -117,8 +120,12 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { } /** Creates the jwt vendor and returns a mock for validation if needed */ - private DynamicConfigModel createMockJwtVendorInTokenManager() { - final Settings settings = Settings.builder().put("enabled", true).build(); + private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncryptionKey) { + final Settings settings = Settings.builder() + .put("enabled", true) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", (includeEncryptionKey ? "1234567890" : null)) + .build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); @@ -211,11 +218,9 @@ public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenThrow( - new RuntimeException("foobar") - ); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenThrow(new RuntimeException("foobar")); final OpenSearchSecurityException exception = assertThrows( OpenSearchSecurityException.class, () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) @@ -237,10 +242,10 @@ public void issueOnBehalfOfToken_success() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenReturn(authToken); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)); assertThat(returnedToken, equalTo(authToken)); @@ -250,28 +255,94 @@ public void issueOnBehalfOfToken_success() throws Exception { } @Test - public void issueApiToken_success() throws Exception { + public void testCreateJwtWithNegativeExpiry() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", -300L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); + } + + @Test + public void testCreateJwtWithExceededExpiry() throws Exception { doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); when(threadPool.getThreadContext()).thenReturn(threadContext); final ConfigModel configModel = mock(ConfigModel.class); tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); - final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. + ArgumentCaptor longCaptor = ArgumentCaptor.forClass(Long.class); + verify(jwtVendor).createJwt(any(), any(), any(), longCaptor.capture()); - assertThat(returnedToken, equalTo(authToken)); + assertThat(600L, equalTo(longCaptor.getValue())); + } - verify(cs).getClusterName(); - verify(threadPool).getThreadContext(); + @Test + public void testCreateJwtWithBadEncryptionKey() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(false); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); } @Test - public void encryptCallsJwtEncrypt() throws Exception { + public void testCreateJwtWithBadRoles() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(null); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); + } + + @Test + public void issueApiToken_success() throws Exception { doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); @@ -279,28 +350,15 @@ public void encryptCallsJwtEncrypt() throws Exception { final ConfigModel configModel = mock(ConfigModel.class); tokenManager.onConfigModelChanged(configModel); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(false); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); assertThat(returnedToken, equalTo(authToken)); verify(cs).getClusterName(); verify(threadPool).getThreadContext(); } - - @Test - public void testEncryptTokenCallsJwtEncrypt() throws Exception { - String tokenToEncrypt = "test-token"; - String encryptedToken = "encrypted-test-token"; - createMockJwtVendorInTokenManager(); - when(jwtVendor.encryptString(tokenToEncrypt)).thenReturn(encryptedToken); - - String result = tokenManager.encryptToken(tokenToEncrypt); - - assertThat(result, equalTo(encryptedToken)); - verify(jwtVendor).encryptString(tokenToEncrypt); - } } diff --git a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java index da35226d62..9fdba2b407 100644 --- a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java @@ -31,6 +31,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.NullAuditLog; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; @@ -160,7 +161,8 @@ PrivilegesEvaluator createPrivilegesEvaluator(SecurityDynamicConfiguration Date: Fri, 21 Feb 2025 11:23:19 -0500 Subject: [PATCH 4/5] Subset of permissions check on creation (#5012) Signed-off-by: Derek Ho --- .../privileges/ActionPrivilegesTest.java | 6 +- .../security/OpenSearchSecurityPlugin.java | 32 ++++- .../action/apitokens/ApiTokenAction.java | 109 ++++++++++---- .../apitokens/ApiTokenIndexHandler.java | 64 ++------- .../action/apitokens/ApiTokenRepository.java | 1 - .../auditlog/impl/AbstractAuditLog.java | 1 - .../security/dlic/rest/api/Endpoint.java | 3 +- .../api/RestApiAdminPrivilegesEvaluator.java | 1 + .../security/http/ApiTokenAuthenticator.java | 1 - .../action/apitokens/ApiTokenActionTest.java | 135 +++++++++++++++++- .../apitokens/ApiTokenAuthenticatorTest.java | 1 + 11 files changed, 263 insertions(+), 91 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index bfe6e600c9..01f7ad9f3d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -39,7 +39,6 @@ import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; import org.opensearch.security.action.apitokens.ApiToken; -import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; @@ -50,9 +49,8 @@ import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; -import org.mockito.Mockito; - import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.security.privileges.ActionPrivilegesTest.IndexPrivileges.IndicesAndAliases.resolved; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isAllowed; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isPartiallyOk; @@ -342,6 +340,7 @@ public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { "apitoken:" + token, new Permissions(List.of("CLUSTER_ALL"), List.of()) ); + // Explicit succeeds assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); // Not explicit succeeds @@ -1152,7 +1151,6 @@ static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { static RoleBasedPrivilegesEvaluationContext ctxWithUserName(String userName, String... roles) { User user = new User(userName); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - ApiTokenRepository mockRepository = Mockito.mock(ApiTokenRepository.class); return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 1d5091ca04..c68c4fb610 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -259,7 +259,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; - private volatile ApiTokenRepository ar; + private volatile ApiTokenRepository apiTokenRepository; private volatile AdminDNs adminDns; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); @@ -649,7 +649,21 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(ar)); + handlers.add( + new ApiTokenAction( + Objects.requireNonNull(threadPool), + cr, + evaluator, + settings, + adminDns, + auditLog, + configPath, + principalExtractor, + apiTokenRepository, + cs, + indexNameExpressionResolver + ) + ); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -1111,7 +1125,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); tokenManager = new SecurityTokenManager(cs, threadPool, userService); - ar = new ApiTokenRepository(localClient, clusterService, tokenManager); + apiTokenRepository = new ApiTokenRepository(localClient, clusterService, tokenManager); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1128,7 +1142,7 @@ public Collection createComponents( cih, irr, namedXContentRegistry.get(), - ar + apiTokenRepository ); dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); @@ -1170,7 +1184,7 @@ public Collection createComponents( configPath, compatConfig ); - dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher, ar); + dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher, apiTokenRepository); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); @@ -1220,7 +1234,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); - components.add(ar); + components.add(apiTokenRepository); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -2143,7 +2157,11 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); - return Collections.singletonList(systemIndexDescriptor); + final SystemIndexDescriptor apiTokenSystemIndexDescriptor = new SystemIndexDescriptor( + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, + "Security API token index" + ); + return List.of(systemIndexDescriptor, apiTokenSystemIndexDescriptor); } @Override 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 0b6a7b320a..eddafd79ee 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -12,6 +12,7 @@ package org.opensearch.security.action.apitokens; import java.io.IOException; +import java.nio.file.Path; import java.time.Instant; import java.util.Collections; import java.util.List; @@ -24,14 +25,29 @@ import org.apache.logging.log4j.Logger; import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; 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.auditlog.AuditLog; +import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.dlic.rest.api.Endpoint; +import org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; +import org.opensearch.security.dlic.rest.support.Utils; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.ssl.transport.PrincipalExtractor; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.threadpool.ThreadPool; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -44,23 +60,57 @@ import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; import static org.opensearch.security.util.ParsingUtils.safeMapList; import static org.opensearch.security.util.ParsingUtils.safeStringList; public class ApiTokenAction extends BaseRestHandler { - private ApiTokenRepository apiTokenRepository; + private final ApiTokenRepository apiTokenRepository; public Logger log = LogManager.getLogger(this.getClass()); - - private static final List ROUTES = addRoutesPrefix( - ImmutableList.of( - new RestHandler.Route(POST, "/apitokens"), - new RestHandler.Route(DELETE, "/apitokens"), - new RestHandler.Route(GET, "/apitokens") - ) + private final ThreadPool threadPool; + private final ConfigurationRepository configurationRepository; + private final PrivilegesEvaluator privilegesEvaluator; + private final SecurityApiDependencies securityApiDependencies; + private final ClusterService clusterService; + private final IndexNameExpressionResolver indexNameExpressionResolver; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) ); - public ApiTokenAction(ApiTokenRepository apiTokenRepository) { + public ApiTokenAction( + ThreadPool threadpool, + ConfigurationRepository configurationRepository, + PrivilegesEvaluator privilegesEvaluator, + Settings settings, + AdminDNs adminDns, + AuditLog auditLog, + Path configPath, + PrincipalExtractor principalExtractor, + ApiTokenRepository apiTokenRepository, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver + ) { this.apiTokenRepository = apiTokenRepository; + this.threadPool = threadpool; + this.configurationRepository = configurationRepository; + this.privilegesEvaluator = privilegesEvaluator; + this.securityApiDependencies = new SecurityApiDependencies( + adminDns, + configurationRepository, + privilegesEvaluator, + new RestApiPrivilegesEvaluator(settings, adminDns, privilegesEvaluator, principalExtractor, configPath, threadPool), + new RestApiAdminPrivilegesEvaluator( + threadPool.getThreadContext(), + privilegesEvaluator, + adminDns, + settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) + ), + auditLog, + settings + ); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; } @Override @@ -69,22 +119,28 @@ public String getName() { } @Override - public List routes() { + public List routes() { return ROUTES; } @Override protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { - // TODO: Authorize this API properly - switch (request.method()) { - case POST: - return handlePost(request, client); - case DELETE: - return handleDelete(request, client); - case GET: - return handleGet(request, client); - default: - throw new IllegalArgumentException(request.method() + " not supported"); + authorizeSecurityAccess(request); + return doPrepareRequest(request, client); + } + + RestChannelConsumer doPrepareRequest(RestRequest request, NodeClient client) { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + return switch (request.method()) { + case POST -> handlePost(request, client); + case DELETE -> handleDelete(request, client); + case GET -> handleGet(request, client); + default -> throw new IllegalArgumentException(request.method() + " not supported"); + }; } } @@ -119,8 +175,6 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { return channel -> { - final XContentBuilder builder = channel.newBuilder(); - BytesRestResponse response; try { final Map requestBody = request.contentOrSourceParamParser().map(); validateRequestParameters(requestBody); @@ -245,8 +299,6 @@ void validateIndexPermissionsList(List> indexPermsList) { private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { return channel -> { - final XContentBuilder builder = channel.newBuilder(); - BytesRestResponse response; try { final Map requestBody = request.contentOrSourceParamParser().map(); @@ -295,4 +347,11 @@ private void sendErrorResponse(RestChannel channel, RestStatus status, String er } } + protected void authorizeSecurityAccess(RestRequest request) throws IOException { + // Check if user has security API access + if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) + || securityApiDependencies.restApiPrivilegesEvaluator().checkAccessPermissions(request, Endpoint.APITOKENS) == null)) { + throw new SecurityException("User does not have required security API access"); + } + } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java index 488229a319..9145ee4bb1 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -26,7 +26,6 @@ import org.opensearch.action.search.SearchResponse; import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.action.ActionListener; @@ -41,7 +40,6 @@ import org.opensearch.index.reindex.DeleteByQueryRequest; import org.opensearch.search.SearchHit; import org.opensearch.search.builder.SearchSourceBuilder; -import org.opensearch.security.dlic.rest.support.Utils; import org.opensearch.security.support.ConfigConstants; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; @@ -57,14 +55,8 @@ public ApiTokenIndexHandler(Client client, ClusterService clusterService) { this.clusterService = clusterService; } - public String indexTokenMetadata(ApiToken token) { - // TODO: move this out of index handler class, potentially create a layer in between baseresthandler and abstractapiaction which can - // abstract this complexity away - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + public void indexTokenMetadata(ApiToken token) { + try { XContentBuilder builder = XContentFactory.jsonBuilder(); String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); @@ -77,10 +69,7 @@ public String indexTokenMetadata(ApiToken token) { LOGGER.error(failResponse.getMessage()); LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); }); - client.index(request, irListener); - return token.getName(); - } catch (IOException e) { throw new RuntimeException(e); } @@ -88,32 +77,21 @@ public String indexTokenMetadata(ApiToken token) { } public void deleteToken(String name) throws ApiTokenException { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( - QueryBuilders.matchQuery(NAME_FIELD, name) - ).setRefresh(true); + DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( + QueryBuilders.matchQuery(NAME_FIELD, name) + ).setRefresh(true); - BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); + BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); - long deletedDocs = response.getDeleted(); + long deletedDocs = response.getDeleted(); - if (deletedDocs == 0) { - throw new ApiTokenException("No token found with name " + name); - } - LOGGER.info("Deleted " + deletedDocs + " documents"); + if (deletedDocs == 0) { + throw new ApiTokenException("No token found with name " + name); } } public Map getTokenMetadatas() { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + try { SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); searchRequest.source(new SearchSourceBuilder()); @@ -145,24 +123,12 @@ public Boolean apiTokenIndexExists() { } public void createApiTokenIndexIfAbsent() { - // TODO: Decide if this should be done at bootstrap if (!apiTokenIndexExists()) { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - final Map indexSettings = ImmutableMap.of( - "index.number_of_shards", - 1, - "index.auto_expand_replicas", - "0-all" - ); - final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( - indexSettings - ); - client.admin().indices().create(createIndexRequest); - } + final Map indexSettings = ImmutableMap.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( + indexSettings + ); + client.admin().indices().create(createIndexRequest); } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index b1f99bdbd6..850fad0b66 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -89,7 +89,6 @@ public String createApiToken( Long expiration ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); - // TODO: Add validation on whether user is creating a token with a subset of their permissions ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration); apiTokenIndexHandler.indexTokenMetadata(apiToken); diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 9a16cd8bfd..5b90f46f83 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -126,7 +126,6 @@ protected AbstractAuditLog( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); - // TODO: support custom api tokens index? this.securityIndicesMatcher = WildcardMatcher.from( List.of( settings.get(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX), diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java index ecc9dcbc59..d5555b445c 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -30,5 +30,6 @@ public enum Endpoint { WHITELIST, ALLOWLIST, NODESDN, - SSL; + SSL, + APITOKENS; } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index faa0217db2..768f9d2f70 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -70,6 +70,7 @@ default String build() { .put(Endpoint.ROLES, action -> buildEndpointPermission(Endpoint.ROLES)) .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) + .put(Endpoint.APITOKENS, action -> buildEndpointPermission(Endpoint.APITOKENS)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) .build(); diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 0a8e3466d7..50b80ad522 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -145,7 +145,6 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } - // TODO: handle revocation different from deletion? if (!apiTokenRepository.isValidToken(subject)) { log.error("Api token is not allowlisted"); return null; diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index e7193710e1..7c52f07ae7 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -17,17 +17,148 @@ import java.util.List; import java.util.Map; +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.threadpool.ThreadPool; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; +import static org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration.fromMap; import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ApiTokenActionTest { + @Mock + private ThreadPool threadPool; + + @Mock + private PrivilegesEvaluator privilegesEvaluator; + + @Mock + private ConfigurationRepository configurationRepository; + + @Mock + private ClusterService clusterService; + @Mock + private ClusterState clusterState; + + @Mock + private Metadata metadata; + + private SecurityDynamicConfiguration actionGroupsConfig; + private SecurityDynamicConfiguration rolesConfig; + private FlattenedActionGroups flattenedActionGroups; + private ApiTokenAction apiTokenAction; + + @Before + public void setUp() throws JsonProcessingException { + // Setup basic action groups + + actionGroupsConfig = SecurityDynamicConfiguration.fromMap( + ImmutableMap.of( + "read_group", + Map.of("allowed_actions", List.of("read", "get", "search")), + "write_group", + Map.of("allowed_actions", List.of("write", "create", "index")) + ), + CType.ACTIONGROUPS + ); + + rolesConfig = fromMap( + ImmutableMap.of( + "read_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "read_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "more_permissable_write_group_lo-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "cluster_monitor", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ), + "alias_group", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs"), "allowed_actions", List.of("read"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ) + + ), + CType.ROLES + ); - private final ApiTokenAction apiTokenAction = new ApiTokenAction(mock(ApiTokenRepository.class)); + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + + apiTokenAction = new ApiTokenAction( + + threadPool, + configurationRepository, + privilegesEvaluator, + Settings.EMPTY, + null, + null, + null, + null, + null, + clusterService, + null + ); + + } @Test public void testCreateIndexPermission() { 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 c24c632fa0..b6c5e0b0f1 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -48,6 +48,7 @@ public class ApiTokenAuthenticatorTest { private ApiTokenAuthenticator authenticator; @Mock private Logger log; + @Mock private ApiTokenRepository apiTokenRepository; From 8750e8bc18d178fad04fe6f41c67faa8b230dcf8 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 24 Mar 2025 10:30:32 -0400 Subject: [PATCH 5/5] Change API token index actions to use action listeners and limit to 100 tokens outstanding (#5147) Signed-off-by: Derek Ho --- .../security/action/apitokens/ApiToken.java | 6 +- .../action/apitokens/ApiTokenAction.java | 188 ++++++++++-------- .../apitokens/ApiTokenIndexHandler.java | 80 ++++---- .../action/apitokens/ApiTokenRepository.java | 52 +++-- .../apitokens/ApiTokenIndexHandlerTest.java | 122 ++++++------ .../apitokens/ApiTokenRepositoryTest.java | 149 ++++++++++---- .../action/apitokens/ApiTokenTest.java | 4 +- .../security/util/ActionListenerUtils.java | 71 +++++++ 8 files changed, 433 insertions(+), 239 deletions(-) create mode 100644 src/test/java/org/opensearch/security/util/ActionListenerUtils.java diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java index 6a81ad9f4d..b790f0d38f 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -22,7 +22,7 @@ public class ApiToken implements ToXContent { public static final String NAME_FIELD = "name"; - public static final String CREATION_TIME_FIELD = "creation_time"; + public static final String ISSUED_AT_FIELD = "iat"; public static final String CLUSTER_PERMISSIONS_FIELD = "cluster_permissions"; public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; public static final String INDEX_PATTERN_FIELD = "index_pattern"; @@ -149,7 +149,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { case NAME_FIELD: name = parser.text(); break; - case CREATION_TIME_FIELD: + case ISSUED_AT_FIELD: creationTime = Instant.ofEpochMilli(parser.longValue()); break; case EXPIRATION_FIELD: @@ -227,7 +227,7 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params xContentBuilder.field(NAME_FIELD, name); xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); - xContentBuilder.field(CREATION_TIME_FIELD, creationTime.toEpochMilli()); + xContentBuilder.field(ISSUED_AT_FIELD, creationTime.toEpochMilli()); xContentBuilder.endObject(); return xContentBuilder; } 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 eddafd79ee..448c5fecc1 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -24,7 +24,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; @@ -48,16 +47,17 @@ import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.security.support.ConfigConstants; import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.node.NodeClient; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; import static org.opensearch.rest.RestRequest.Method.POST; import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD; -import static org.opensearch.security.action.apitokens.ApiToken.CREATION_TIME_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.ISSUED_AT_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; @@ -146,30 +146,32 @@ RestChannelConsumer doPrepareRequest(RestRequest request, NodeClient client) { private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { return channel -> { - final XContentBuilder builder = channel.newBuilder(); - BytesRestResponse response; - try { - Map tokens = apiTokenRepository.getApiTokens(); - - builder.startArray(); - for (ApiToken token : tokens.values()) { - builder.startObject(); - builder.field(NAME_FIELD, token.getName()); - builder.field(CREATION_TIME_FIELD, token.getCreationTime().toEpochMilli()); - builder.field(EXPIRATION_FIELD, token.getExpiration()); - builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); - builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); - builder.endObject(); + apiTokenRepository.getApiTokens(ActionListener.wrap(tokens -> { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startArray(); + for (ApiToken token : tokens.values()) { + builder.startObject(); + builder.field(NAME_FIELD, token.getName()); + builder.field(ISSUED_AT_FIELD, token.getCreationTime().toEpochMilli()); + builder.field(EXPIRATION_FIELD, token.getExpiration()); + builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); + builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); + builder.endObject(); + } + builder.endArray(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + builder.close(); + channel.sendResponse(response); + } catch (final Exception exception) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.endArray(); + }, exception -> { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + + })); - response = new BytesRestResponse(RestStatus.OK, builder); - } catch (final Exception exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); - } - builder.close(); - channel.sendResponse(response); }; } @@ -181,43 +183,76 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { List clusterPermissions = extractClusterPermissions(requestBody); List indexPermissions = extractIndexPermissions(requestBody); - - String token = apiTokenRepository.createApiToken( - (String) requestBody.get(NAME_FIELD), - clusterPermissions, - indexPermissions, - (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) + String name = (String) requestBody.get(NAME_FIELD); + long expiration = (Long) requestBody.getOrDefault( + EXPIRATION_FIELD, + Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30) ); - // Then trigger the update action - ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); - client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { - @Override - public void onResponse(ApiTokenUpdateResponse updateResponse) { - try { + // First check token count + apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> { + if (tokenCount >= 100) { + sendErrorResponse( + channel, + RestStatus.TOO_MANY_REQUESTS, + "Maximum limit of 100 API tokens reached. Please delete existing tokens before creating new ones." + ); + return; + } + + apiTokenRepository.createApiToken( + name, + clusterPermissions, + indexPermissions, + expiration, + wrapWithCacheRefresh(ActionListener.wrap(token -> { XContentBuilder builder = channel.newBuilder(); builder.startObject(); - builder.field("Api Token: ", token); + builder.field("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) { - sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + builder.close(); + + }, + createException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to create token: " + createException.getMessage() + ) + ), client) + ); + }, + countException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to get token count: " + countException.getMessage() + ) + )); + + } catch (Exception e) { + sendErrorResponse(channel, RestStatus.BAD_REQUEST, "Invalid request: " + e.getMessage()); } }; } + private ActionListener wrapWithCacheRefresh(ActionListener listener, NodeClient client) { + return ActionListener.wrap(response -> { + try { + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute( + ApiTokenUpdateAction.INSTANCE, + updateRequest, + ActionListener.wrap( + updateResponse -> listener.onResponse(response), + exception -> listener.onFailure(new ApiTokenException("Failed to refresh cache", exception)) + ) + ); + } catch (Exception e) { + listener.onFailure(new ApiTokenException("Failed to refresh cache after operation", e)); + } + }, listener::onFailure); + } + /** * Extracts cluster permissions from the request body */ @@ -303,37 +338,30 @@ private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) final Map requestBody = request.contentOrSourceParamParser().map(); validateRequestParameters(requestBody); - apiTokenRepository.deleteApiToken((String) requestBody.get(NAME_FIELD)); - - 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) { - sendErrorResponse(channel, RestStatus.NOT_FOUND, exception.getMessage()); + apiTokenRepository.deleteApiToken( + (String) requestBody.get(NAME_FIELD), + wrapWithCacheRefresh(ActionListener.wrap(ignored -> { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "Token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + }, + deleteException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to delete token: " + deleteException.getMessage() + ) + ), client) + ); } catch (final Exception exception) { - sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + RestStatus status = RestStatus.INTERNAL_SERVER_ERROR; + if (exception instanceof ApiTokenException) { + status = RestStatus.NOT_FOUND; + } + sendErrorResponse(channel, status, exception.getMessage()); } }; - } private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java index 9145ee4bb1..d34368b34a 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -21,10 +21,7 @@ import org.opensearch.action.admin.indices.create.CreateIndexRequest; import org.opensearch.action.index.IndexRequest; -import org.opensearch.action.index.IndexResponse; import org.opensearch.action.search.SearchRequest; -import org.opensearch.action.search.SearchResponse; -import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.common.xcontent.XContentType; @@ -35,12 +32,12 @@ import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; import org.opensearch.index.query.QueryBuilders; -import org.opensearch.index.reindex.BulkByScrollResponse; import org.opensearch.index.reindex.DeleteByQueryAction; import org.opensearch.index.reindex.DeleteByQueryRequest; import org.opensearch.search.SearchHit; import org.opensearch.search.builder.SearchSourceBuilder; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.transport.client.Client; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; @@ -55,66 +52,69 @@ public ApiTokenIndexHandler(Client client, ClusterService clusterService) { this.clusterService = clusterService; } - public void indexTokenMetadata(ApiToken token) { + public void indexTokenMetadata(ApiToken token, ActionListener listener) { try { - XContentBuilder builder = XContentFactory.jsonBuilder(); String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); - ActionListener irListener = ActionListener.wrap(idxResponse -> { + client.index(request, ActionListener.wrap(indexResponse -> { LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }, (failResponse) -> { - LOGGER.error(failResponse.getMessage()); + listener.onResponse(null); + }, exception -> { + LOGGER.error(exception.getMessage()); LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }); - client.index(request, irListener); + listener.onFailure(exception); + })); } catch (IOException e) { throw new RuntimeException(e); } - } - public void deleteToken(String name) throws ApiTokenException { + public void deleteToken(String name, ActionListener listener) { DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( QueryBuilders.matchQuery(NAME_FIELD, name) ).setRefresh(true); - BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); - - long deletedDocs = response.getDeleted(); - - if (deletedDocs == 0) { - throw new ApiTokenException("No token found with name " + name); - } + client.execute(DeleteByQueryAction.INSTANCE, request, ActionListener.wrap(response -> { + long deletedDocs = response.getDeleted(); + if (deletedDocs == 0) { + listener.onFailure(new ApiTokenException("No token found with name " + name)); + } else { + listener.onResponse(null); + } + }, exception -> listener.onFailure(exception))); } - public Map getTokenMetadatas() { + public void getTokenMetadatas(ActionListener> listener) { try { SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); searchRequest.source(new SearchSourceBuilder()); - SearchResponse response = client.search(searchRequest).actionGet(); - - Map tokens = new HashMap<>(); - for (SearchHit hit : response.getHits().getHits()) { - try ( - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - hit.getSourceRef().streamInput() - ) - ) { - - ApiToken token = ApiToken.fromXContent(parser); - tokens.put(token.getName(), token); + client.search(searchRequest, ActionListener.wrap(response -> { + try { + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + ApiToken token = ApiToken.fromXContent(parser); + tokens.put(token.getName(), token); + } + } + listener.onResponse(tokens); + } catch (IOException e) { + listener.onFailure(e); } - } - return tokens; - } catch (IOException e) { - throw new RuntimeException(e); + }, listener::onFailure)); + } catch (Exception e) { + listener.onFailure(e); } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index 850fad0b66..817bdb23c7 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -13,18 +13,20 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import com.google.common.annotations.VisibleForTesting; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.user.User; +import org.opensearch.transport.client.Client; import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; @@ -36,11 +38,26 @@ public class ApiTokenRepository { private final Map jtis = new ConcurrentHashMap<>(); void reloadApiTokensFromIndex() { - Map tokensFromIndex = apiTokenIndexHandler.getTokenMetadatas(); - jtis.keySet().removeIf(key -> !tokensFromIndex.containsKey(key)); - tokensFromIndex.forEach( - (key, apiToken) -> jtis.put(key, new Permissions(apiToken.getClusterPermissions(), apiToken.getIndexPermissions())) - ); + CompletableFuture> future = new CompletableFuture<>(); + + apiTokenIndexHandler.getTokenMetadatas(new ActionListener>() { + @Override + public void onResponse(Map tokensFromIndex) { + future.complete(tokensFromIndex); + } + + @Override + public void onFailure(Exception e) { + future.completeExceptionally(e); + } + }); + + future.thenAccept(tokensFromIndex -> { + jtis.keySet().removeIf(key -> !tokensFromIndex.containsKey(key)); + tokensFromIndex.forEach( + (key, apiToken) -> jtis.put(key, new Permissions(apiToken.getClusterPermissions(), apiToken.getIndexPermissions())) + ); + }); } public Permissions getApiTokenPermissionsForUser(User user) { @@ -82,25 +99,32 @@ static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, Sec return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager); } - public String createApiToken( + public void createApiToken( String name, List clusterPermissions, List indexPermissions, - Long expiration + Long expiration, + ActionListener listener ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration); - apiTokenIndexHandler.indexTokenMetadata(apiToken); - return token.getCompleteToken(); + apiTokenIndexHandler.indexTokenMetadata( + apiToken, + ActionListener.wrap(unused -> listener.onResponse(token.getCompleteToken()), exception -> listener.onFailure(exception)) + ); + } + + public void deleteApiToken(String name, ActionListener listener) throws ApiTokenException, IndexNotFoundException { + apiTokenIndexHandler.deleteToken(name, listener); } - public void deleteApiToken(String name) throws ApiTokenException, IndexNotFoundException { - apiTokenIndexHandler.deleteToken(name); + public void getApiTokens(ActionListener> listener) { + apiTokenIndexHandler.getTokenMetadatas(listener); } - public Map getApiTokens() throws IndexNotFoundException { - return apiTokenIndexHandler.getTokenMetadatas(); + public void getTokenCount(ActionListener listener) { + getApiTokens(ActionListener.wrap(tokens -> listener.onResponse((long) tokens.size()), listener::onFailure)); } } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java index 9b3b8638e2..b74e7073fc 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -26,9 +26,6 @@ import org.opensearch.action.index.IndexResponse; import org.opensearch.action.search.SearchRequest; import org.opensearch.action.search.SearchResponse; -import org.opensearch.action.support.PlainActionFuture; -import org.opensearch.client.Client; -import org.opensearch.client.IndicesAdminClient; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; @@ -36,7 +33,6 @@ import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.bytes.BytesReference; -import org.opensearch.core.index.shard.ShardId; import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.index.query.MatchQueryBuilder; @@ -46,6 +42,9 @@ import org.opensearch.search.SearchHit; import org.opensearch.search.SearchHits; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.util.ActionListenerUtils.TestActionListener; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.client.IndicesAdminClient; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -56,7 +55,6 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; -import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; @@ -66,6 +64,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +@SuppressWarnings("unchecked") public class ApiTokenIndexHandlerTest { @Mock @@ -107,7 +106,6 @@ public void testCreateApiTokenIndexWhenIndexNotExist() { indexHandler.createApiTokenIndexIfAbsent(); ArgumentCaptor captor = ArgumentCaptor.forClass(CreateIndexRequest.class); - verify(indicesAdminClient).create(captor.capture()); assertThat(captor.getValue().index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); } @@ -125,16 +123,27 @@ public void testCreateApiTokenIndexWhenIndexExists() { public void testDeleteApiTokeCallsDeleteByQueryWithSuppliedName() { when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); String tokenName = "token"; - try { - indexHandler.deleteToken(tokenName); - } catch (Exception e) { - // Ignore - } + + TestActionListener listener = new TestActionListener<>(); + + doAnswer(invocation -> { + DeleteByQueryRequest request = invocation.getArgument(1); + ActionListener parentListener = invocation.getArgument(2); + + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + + parentListener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + indexHandler.deleteToken(tokenName, listener); ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteByQueryRequest.class); - verify(client).execute(eq(DeleteByQueryAction.INSTANCE), captor.capture()); + verify(client).execute(eq(DeleteByQueryAction.INSTANCE), captor.capture(), any(ActionListener.class)); + + listener.assertSuccess(); - // Captured request has the correct name DeleteByQueryRequest capturedRequest = captor.getValue(); MatchQueryBuilder query = (MatchQueryBuilder) capturedRequest.getSearchRequest().source().query(); assertThat(query.fieldName(), equalTo(NAME_FIELD)); @@ -145,38 +154,39 @@ public void testDeleteApiTokeCallsDeleteByQueryWithSuppliedName() { public void testDeleteTokenThrowsExceptionWhenNoDocumentsDeleted() { when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); - PlainActionFuture future = new PlainActionFuture<>(); - BulkByScrollResponse response = mock(BulkByScrollResponse.class); - when(response.getDeleted()).thenReturn(0L); - future.onResponse(response); - when(client.execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class))).thenReturn(future); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(0L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); String tokenName = "nonexistent-token"; - try { - indexHandler.deleteToken(tokenName); - fail("Expected ApiTokenException to be thrown"); - } catch (ApiTokenException e) { - assertThat(e.getMessage(), equalTo("No token found with name " + tokenName)); - } + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("No token found with name " + tokenName)); } @Test public void testDeleteTokenSucceedsWhenDocumentIsDeleted() { when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); - // 1 deleted document - PlainActionFuture future = new PlainActionFuture<>(); - BulkByScrollResponse response = mock(BulkByScrollResponse.class); - when(response.getDeleted()).thenReturn(1L); - future.onResponse(response); - when(client.execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class))).thenReturn(future); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); String tokenName = "existing-token"; - try { - indexHandler.deleteToken(tokenName); - } catch (ApiTokenException e) { - fail("Should not have thrown exception"); - } + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, listener); + + listener.assertSuccess(); } @Test @@ -198,35 +208,24 @@ public void testIndexTokenStoresTokenPayload() { Long.MAX_VALUE ); - // Mock the index method with ActionListener - @SuppressWarnings("unchecked") - ArgumentCaptor> listenerCaptor = - ArgumentCaptor.forClass((Class>) (Class) ActionListener.class); - + // Mock the index response doAnswer(invocation -> { - ActionListener listener = listenerCaptor.getValue(); - listener.onResponse(new IndexResponse( - new ShardId(".opensearch_security_api_tokens", "_na_", 1), - "1", - 0, - 1, - 1, - true - )); + ActionListener listener = invocation.getArgument(1); + listener.onResponse(mock(IndexResponse.class)); return null; - }).when(client).index(any(IndexRequest.class), listenerCaptor.capture()); + }).when(client).index(any(IndexRequest.class), any(ActionListener.class)); + TestActionListener listener = new TestActionListener<>(); + indexHandler.indexTokenMetadata(token, listener); - indexHandler.indexTokenMetadata(token); + listener.assertSuccess(); - // Verify the index request ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); - verify(client).index(requestCaptor.capture(), listenerCaptor.capture()); + verify(client).index(requestCaptor.capture(), any(ActionListener.class)); IndexRequest capturedRequest = requestCaptor.getValue(); assertThat(capturedRequest.index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); - // verify contents String source = capturedRequest.source().utf8ToString(); assertThat(source, containsString("test-token-description")); assertThat(source, containsString("cluster:admin/something")); @@ -280,14 +279,16 @@ public void testGetTokenPayloads() throws IOException { SearchHits searchHits = new SearchHits(hits, new TotalHits(2, TotalHits.Relation.EQUAL_TO), 1.0f); when(searchResponse.getHits()).thenReturn(searchHits); - // Mock client search call - PlainActionFuture future = new PlainActionFuture<>(); - future.onResponse(searchResponse); - when(client.search(any(SearchRequest.class))).thenReturn(future); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(searchResponse); + return null; + }).when(client).search(any(SearchRequest.class), any(ActionListener.class)); - // Get tokens and verify - Map resultTokens = indexHandler.getTokenMetadatas(); + TestActionListener> listener = new TestActionListener<>(); + indexHandler.getTokenMetadatas(listener); + Map resultTokens = listener.assertSuccess(); assertThat(resultTokens.size(), equalTo(2)); assertThat(resultTokens.containsKey("token1-description"), is(true)); assertThat(resultTokens.containsKey("token2-description"), is(true)); @@ -298,5 +299,4 @@ public void testGetTokenPayloads() throws IOException { ApiToken resultToken2 = resultTokens.get("token2-description"); assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); } - } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java index 89f8b950cd..a7a43cb862 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -13,35 +13,43 @@ import java.io.IOException; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.opensearch.core.action.ActionListener; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.user.User; +import org.opensearch.security.util.ActionListenerUtils.TestActionListener; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; -import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SuppressWarnings("unchecked") @RunWith(MockitoJUnitRunner.class) public class ApiTokenRepositoryTest { @Mock @@ -61,9 +69,17 @@ public void setUp() { public void testDeleteApiToken() throws ApiTokenException { String tokenName = "test-token"; - repository.deleteApiToken(tokenName); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); - verify(apiTokenIndexHandler).deleteToken(tokenName); + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + listener.assertSuccess(); + verify(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); } @Test @@ -86,19 +102,25 @@ public void testGetApiTokenPermissionsForUser() throws ApiTokenException { assertEquals(List.of("cluster_all"), permissionsForApiTokenExists.getClusterPerm()); assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getAllowedActions()); assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getIndexPatterns()); - } @Test public void testGetApiTokens() throws IndexNotFoundException { Map expectedTokens = new HashMap<>(); expectedTokens.put("token1", new ApiToken("token1", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); - when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(expectedTokens); - Map result = repository.getApiTokens(); + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(expectedTokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + TestActionListener> listener = new TestActionListener<>(); + repository.getApiTokens(listener); + + Map result = listener.assertSuccess(); assertThat(result, equalTo(expectedTokens)); - verify(apiTokenIndexHandler).getTokenMetadatas(); + verify(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); } @Test @@ -115,35 +137,68 @@ public void testCreateApiToken() { when(bearerToken.getCompleteToken()).thenReturn(completeToken); when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); - String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); - - verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); - verify(securityTokenManager).issueApiToken(any(), any()); - verify(apiTokenIndexHandler).indexTokenMetadata( - argThat( - token -> token.getName().equals(tokenName) - && token.getClusterPermissions().equals(clusterPermissions) - && token.getIndexPermissions().equals(indexPermissions) - && token.getExpiration().equals(expiration) - ) - ); - assertThat(result, equalTo(completeToken)); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).indexTokenMetadata(any(ApiToken.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener() { + @Override + public void onResponse(String result) { + try { + assertThat(result, equalTo(completeToken)); + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); + verify(securityTokenManager).issueApiToken(any(), any()); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) + ), + any(ActionListener.class) + ); + } finally { + super.onResponse(result); + } + } + }; + + repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration, listener); + listener.assertSuccess(); } - @Test(expected = IndexNotFoundException.class) - public void testGetApiTokensThrowsIndexNotFoundException() throws IndexNotFoundException { - when(apiTokenIndexHandler.getTokenMetadatas()).thenThrow(new IndexNotFoundException("test-index")); - - repository.getApiTokens(); - + @Test + public void testGetApiTokensThrowsIndexNotFoundException() { + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onFailure(new IndexNotFoundException("test-index")); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener> listener = new TestActionListener<>(); + repository.getApiTokens(listener); + + Exception e = listener.assertException(IndexNotFoundException.class); + assertThat(e.getMessage(), containsString("test-index")); } - @Test(expected = ApiTokenException.class) - public void testDeleteApiTokenThrowsApiTokenException() throws ApiTokenException { + @Test + public void testDeleteApiTokenThrowsApiTokenException() { String tokenName = "test-token"; - doThrow(new ApiTokenException("Token not found")).when(apiTokenIndexHandler).deleteToken(tokenName); - repository.deleteApiToken(tokenName); + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onFailure(new ApiTokenException("Token not found")); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("Token not found")); } @Test @@ -161,24 +216,40 @@ public void testJtisOperations() { @Test public void testClearJtis() { repository.getJtis().put("testJti", new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(Collections.emptyMap()); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + repository.reloadApiTokensFromIndex(); - assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty()); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty())); } @Test public void testReloadApiTokensFromIndexAndParse() throws IOException { - when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(Map.of("test", new ApiToken("test", List.of("cluster:monitor"), List.of(), Long.MAX_VALUE))); + // Setup mock response + Map expectedTokens = Map.of("test", new ApiToken("test", List.of("cluster:monitor"), List.of(), Long.MAX_VALUE)); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(expectedTokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); // Execute the reload repository.reloadApiTokensFromIndex(); - // Verify the cache was updated - assertFalse("Jtis should not be empty after reload", repository.getJtis().isEmpty()); - assertEquals("Should have one JTI entry", 1, repository.getJtis().size()); - assertTrue("Should contain testJti", repository.getJtis().containsKey("test")); - // Verify extraction works - assertEquals("Should have one cluster action", List.of("cluster:monitor"), repository.getJtis().get("test").getClusterPerm()); - assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndexPermission()); + // Wait for and verify the async updates + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertFalse("Jtis should not be empty after reload", repository.getJtis().isEmpty()); + assertEquals("Should have one JTI entry", 1, repository.getJtis().size()); + assertTrue("Should contain testJti", repository.getJtis().containsKey("test")); + assertEquals("Should have one cluster action", List.of("cluster:monitor"), repository.getJtis().get("test").getClusterPerm()); + assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndexPermission()); + }); } } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java index 4951507359..922bfaff1e 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -18,8 +18,6 @@ import org.junit.Before; import org.junit.Test; -import org.opensearch.client.Client; -import org.opensearch.client.IndicesAdminClient; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; @@ -30,6 +28,8 @@ import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.client.IndicesAdminClient; import org.mockito.Mock; diff --git a/src/test/java/org/opensearch/security/util/ActionListenerUtils.java b/src/test/java/org/opensearch/security/util/ActionListenerUtils.java new file mode 100644 index 0000000000..8ec37649b1 --- /dev/null +++ b/src/test/java/org/opensearch/security/util/ActionListenerUtils.java @@ -0,0 +1,71 @@ +/* + * 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.util; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.opensearch.core.action.ActionListener; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.fail; + +public class ActionListenerUtils { + public static class TestActionListener implements ActionListener { + private final CountDownLatch latch = new CountDownLatch(1); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicReference exception = new AtomicReference<>(); + + @Override + public void onResponse(T result) { + response.set(result); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + exception.set(e); + latch.countDown(); + } + + public T assertSuccess() { + waitForCompletion(); + if (exception.get() != null) { + fail("Expected success but got exception: " + exception.get()); + } + return response.get(); + } + + public Exception assertException(Class expectedExceptionClass) { + waitForCompletion(); + Exception actualException = exception.get(); + if (actualException == null) { + fail("Expected exception of type " + expectedExceptionClass.getSimpleName() + " but operation succeeded"); + } + assertThat("Exception type mismatch", actualException, instanceOf(expectedExceptionClass)); + return actualException; + } + + void waitForCompletion() { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("Test timed out waiting for response"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Test interrupted: " + e.getMessage()); + } + } + } +}