diff --git a/CHANGELOG.md b/CHANGELOG.md index d00370b5d4..c4a77cfea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Make security plugin aware of FIPS build param (-Pcrypto.standard=FIPS-140-3) ([#5952](https://github.com/opensearch-project/security/pull/5952)) - Hardens input validation for resource sharing APIs ([#5831](https://github.com/opensearch-project/security/pull/5831) - Optimize getFieldFilter to only return a predicate when index has FLS restrictions for user ([#5777](https://github.com/opensearch-project/security/pull/5777)) +- Introduce API Tokens with `cluster_permissions` and `index_permissions` directly associated with the token ([#5443](https://github.com/opensearch-project/security/pull/5443)) - Performance optimizations for building internal authorization data structures upon config updates ([#5988](https://github.com/opensearch-project/security/pull/5988)) - Make encryption_key optional for obo token authenticator ([#6017](https://github.com/opensearch-project/security/pull/6017) - [Resource Sharing] Using custom action prefixes for sample resource plugin ([#6020](https://github.com/opensearch-project/security/pull/6020) diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java new file mode 100644 index 0000000000..35d544d4bc --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java @@ -0,0 +1,381 @@ +/* + * 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.io.IOException; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.http.HttpStatus; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.test.framework.ApiTokenConfig; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS; + +public class ApiTokenTest { + + public static final String POINTER_USERNAME = "/user_name"; + + static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); + static final TestSecurityConfig.User REGULAR_USER = new TestSecurityConfig.User("regular_user"); + + private static final String CREATE_API_TOKEN_PATH = "_plugins/_security/api/apitokens"; + public static final String ADMIN_USER_NAME = "admin"; + public static final String REGULAR_USER_NAME = "regular_user"; + public static final String DEFAULT_PASSWORD = "secret"; + public static final String NEW_PASSWORD = "testPassword123!!"; + public static final String TEST_TOKEN_PAYLOAD = """ + { + "name": "test-token", + "cluster_permissions": ["cluster_monitor"] + } + """; + + public static final String TEST_TOKEN_WITH_INDEX_PERMISSIONS_PAYLOAD = """ + { + "name": "test-token-index", + "cluster_permissions": [], + "index_permissions": [{ + "index_pattern": ["test-index-*"], + "allowed_actions": ["indices:data/read/search"] + }] + } + """; + + public static final String TEST_TOKEN_INVALID_PAYLOAD = """ + { + "name": "test-token", + "cluster_permissions": ["cluster_monitor"], + "expiration": "wrong" + } + """; + + public static final String TEST_TOKEN_INVALID_PARAMETER_IN_PAYLOAD = """ + { + "name": "test-token", + "cluster_permissions": ["cluster_monitor"], + "foo": "bar" + } + """; + + public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \"" + + DEFAULT_PASSWORD + + "\", \"password\": \"" + + NEW_PASSWORD + + "\" }"; + + private static ApiTokenConfig defaultApiTokenConfig() { + return new ApiTokenConfig().enabled(true); + } + + @ClassRule + public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .anonymousAuth(false) + .users(ADMIN_USER, REGULAR_USER) + .nodeSettings( + Map.of( + SECURITY_RESTAPI_ROLES_ENABLED, + List.of("user_" + ADMIN_USER.getName() + "__" + ALL_ACCESS.getName()), + "plugins.security.unsupported.restapi.allow_securityconfig_modification", + true + ) + ) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .apiToken(defaultApiTokenConfig()) + .build(); + + @Before + public void before() { + patchApiTokenConfig(defaultApiTokenConfig()); + deleteAllApiTokens(); + } + + @Test + public void testAuthInfoEndpoint() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + authenticateWithApiToken(authHeader, HttpStatus.SC_OK); + } + + @Test + public void testCallingClusterHealthWithApiToken_success() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + try (TestRestClient client = cluster.getRestClient(authHeader)) { + TestRestClient.HttpResponse response = client.get("_cluster/health"); + response.assertStatusCode(HttpStatus.SC_OK); + } + } + + @Test + public void shouldNotAuthenticateWithATamperedAPIToken() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + apiToken = apiToken.substring(0, apiToken.length() - 1); // tampering the token + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + authenticateWithApiToken(authHeader, HttpStatus.SC_UNAUTHORIZED); + } + + @Test + public void shouldNotBeAbleToUseTokenToGenerateMoreTokens() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + + try (TestRestClient client = cluster.getRestClient(authHeader)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD); + response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED); + } + } + + @Test + public void testAccountApiForbiddenWithApiToken() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + + try (TestRestClient client = cluster.getRestClient(authHeader)) { + TestRestClient.HttpResponse response = client.putJson("_plugins/_security/api/account", CURRENT_AND_NEW_PASSWORDS); + response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED); + } + } + + @Test + public void testRegularUserShouldNotBeAbleToGenerateApiToken() { + try (TestRestClient client = cluster.getRestClient(REGULAR_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + } + + @Test + public void shouldNotAuthenticateWithInvalidExpiration() { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_INVALID_PAYLOAD); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getTextFromJsonBody("/error"), containsString("failed to parse field [expiration]")); + } + } + + @Test + public void shouldNotAuthenticateWithInvalidAPIParameter() { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_INVALID_PARAMETER_IN_PAYLOAD); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getTextFromJsonBody("/error"), containsString("[create_api_token_request] unknown field [foo]")); + } + } + + @Test + public void shouldNotAllowTokenWhenApiTokensAreDisabled() { + final Header apiTokenHeader = new BasicHeader("Authorization", "ApiKey " + generateApiToken(TEST_TOKEN_PAYLOAD)); + authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_OK); + + // Disable API Tokens via config and see that the authenticator doesn't authorize + patchApiTokenConfig(defaultApiTokenConfig().enabled(false)); + authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_UNAUTHORIZED); + + // Re-enable API Tokens via config and see that the authenticator is working again + patchApiTokenConfig(defaultApiTokenConfig().enabled(true)); + authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_OK); + } + + @Test + public void testApiTokenWithIndexPermissions_canSearchAllowedIndex() { + // Create the allowed index as admin + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + adminClient.putJson("test-index-allowed", "{\"settings\":{\"number_of_shards\":1}}").assertStatusCode(HttpStatus.SC_OK); + } + + String apiToken = generateApiToken(TEST_TOKEN_WITH_INDEX_PERMISSIONS_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + + try (TestRestClient client = cluster.getRestClient(authHeader)) { + // Should be able to search the allowed index pattern + TestRestClient.HttpResponse response = client.get("test-index-allowed/_search"); + response.assertStatusCode(HttpStatus.SC_OK); + + // Should NOT be able to search an index outside the allowed pattern + TestRestClient.HttpResponse forbiddenResponse = client.get("other-index/_search"); + assertThat(forbiddenResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + } + } + + @Test + public void testApiTokenWithIndexPermissions_canWriteAllowedIndex() { + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + adminClient.putJson("test-index-write", "{\"settings\":{\"number_of_shards\":1}}").assertStatusCode(HttpStatus.SC_OK); + } + + String writePayload = """ + { + "name": "test-token-index-write", + "cluster_permissions": ["indices:data/write/bulk"], + "index_permissions": [{ + "index_pattern": ["test-index-write"], + "allowed_actions": ["indices:data/write/index", "indices:data/write/bulk*", "indices:admin/mapping/put"] + }] + } + """; + String apiToken = generateApiToken(writePayload); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + + try (TestRestClient client = cluster.getRestClient(authHeader)) { + TestRestClient.HttpResponse response = client.postJson("test-index-write/_doc", "{\"field\": \"value\"}"); + response.assertStatusCode(HttpStatus.SC_CREATED); + + TestRestClient.HttpResponse forbiddenResponse = client.postJson("other-index/_doc", "{\"field\": \"value\"}"); + assertThat(forbiddenResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + } + } + + @Test + public void testExpiredApiToken_isRejected() { + // Create a token with an expiration in the past (1 ms after epoch) + String expiredPayload = """ + { + "name": "expired-token", + "cluster_permissions": ["cluster_monitor"], + "expiration": 1 + } + """; + try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, expiredPayload); + response.assertStatusCode(HttpStatus.SC_OK); + String expiredToken = response.getTextFromJsonBody("/token").toString(); + Header authHeader = new BasicHeader("Authorization", "ApiKey " + expiredToken); + authenticateWithApiToken(authHeader, HttpStatus.SC_UNAUTHORIZED); + } + } + + @Test + public void testAdminCanRevokeTokenIssuedByAnotherUser() { + // Create token and capture both the plaintext token and the doc id + String apiToken; + String tokenId; + try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD); + response.assertStatusCode(HttpStatus.SC_OK); + apiToken = response.getTextFromJsonBody("/token"); + tokenId = response.getTextFromJsonBody("/id"); + } + Header authHeader = new BasicHeader("Authorization", "ApiKey " + apiToken); + + // Token works before revocation + authenticateWithApiToken(authHeader, HttpStatus.SC_OK); + + // Admin revokes by id + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse revokeResponse = adminClient.delete(CREATE_API_TOKEN_PATH + "/" + tokenId); + revokeResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(revokeResponse.getTextFromJsonBody("/message"), containsString("revoked successfully")); + } + + // Token no longer works after revocation + authenticateWithApiToken(authHeader, HttpStatus.SC_UNAUTHORIZED); + } + + @Test + public void testRevokedTokenAppearsInListWithRevokedAt() { + String tokenId; + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse createResponse = adminClient.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD); + createResponse.assertStatusCode(HttpStatus.SC_OK); + tokenId = createResponse.getTextFromJsonBody("/id"); + } + + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + adminClient.delete(CREATE_API_TOKEN_PATH + "/" + tokenId).assertStatusCode(HttpStatus.SC_OK); + } + + final String revokedId = tokenId; + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse listResponse = adminClient.get(CREATE_API_TOKEN_PATH); + listResponse.assertStatusCode(HttpStatus.SC_OK); + // Find our specific token in the list and verify it has revoked_at + boolean found = false; + for (com.fasterxml.jackson.databind.JsonNode token : listResponse.bodyAsJsonNode()) { + if (revokedId.equals(token.get(ApiToken.ID_FIELD).asText())) { + assertThat(token.has(ApiToken.REVOKED_AT_FIELD), equalTo(true)); + found = true; + break; + } + } + assertThat("Revoked token should appear in list", found, equalTo(true)); + } + } + + private String generateApiToken(String payload) { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, payload); + response.assertStatusCode(HttpStatus.SC_OK); + return response.getTextFromJsonBody("/token").toString(); + } + } + + private void authenticateWithApiToken(Header authHeader, int expectedStatusCode) { + try (TestRestClient client = cluster.getRestClient(authHeader)) { + TestRestClient.HttpResponse response = client.getAuthInfo(); + response.assertStatusCode(expectedStatusCode); + assertThat(response.getStatusCode(), equalTo(expectedStatusCode)); + if (expectedStatusCode == HttpStatus.SC_OK) { + String username = response.getTextFromJsonBody(POINTER_USERNAME); + assertThat(username, startsWith(ApiTokenAuthenticator.API_TOKEN_USER_PREFIX)); + } + } + } + + private void deleteAllApiTokens() { + try (TestRestClient adminClient = cluster.getRestClient(ADMIN_USER)) { + TestRestClient.HttpResponse listResponse = adminClient.get(CREATE_API_TOKEN_PATH); + listResponse.assertStatusCode(HttpStatus.SC_OK); + listResponse.bodyAsJsonNode().forEach(token -> { + // Only revoke tokens that are not already revoked + if (!token.has(ApiToken.REVOKED_AT_FIELD)) { + String id = token.get(ApiToken.ID_FIELD).asText(); + adminClient.delete(CREATE_API_TOKEN_PATH + "/" + id).assertStatusCode(HttpStatus.SC_OK); + } + }); + } + } + + private void patchApiTokenConfig(final ApiTokenConfig apiTokenConfig) { + try (final TestRestClient adminClient = cluster.getRestClient(cluster.getAdminCertificate())) { + final XContentBuilder configBuilder = XContentFactory.jsonBuilder(); + configBuilder.value(apiTokenConfig); + + final String patchBody = "[{ \"op\": \"replace\", \"path\": \"/config/dynamic/api_tokens\", \"value\":" + + configBuilder.toString() + + "}]"; + final var response = adminClient.patch("_plugins/_security/api/securityconfig", patchBody); + response.assertStatusCode(HttpStatus.SC_OK); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/ApiTokenConfig.java b/src/integrationTest/java/org/opensearch/test/framework/ApiTokenConfig.java new file mode 100644 index 0000000000..1619407b0c --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/ApiTokenConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * 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.test.framework; + +import java.io.IOException; + +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenConfig implements ToXContentObject { + private Boolean enabled; + + public ApiTokenConfig enabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field("enabled", enabled); + xContentBuilder.endObject(); + return xContentBuilder; + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index 8b8be94fe5..da244719c7 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -151,6 +151,11 @@ public TestSecurityConfig onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) { return this; } + public TestSecurityConfig apiToken(ApiTokenConfig apiTokenConfig) { + config.apiTokenConfig(apiTokenConfig); + return this; + } + public TestSecurityConfig authc(AuthcDomain authcDomain) { config.authc(authcDomain); return this; @@ -268,6 +273,7 @@ public static class Config implements ToXContentObject { private Boolean doNotFailOnForbidden; private XffConfig xffConfig; private OnBehalfOfConfig onBehalfOfConfig; + private ApiTokenConfig apiTokenConfig; private Map authcDomainMap = new LinkedHashMap<>(); private AuthFailureListeners authFailureListeners; @@ -293,6 +299,11 @@ public Config onBehalfOfConfig(OnBehalfOfConfig onBehalfOfConfig) { return this; } + public Config apiTokenConfig(ApiTokenConfig apiTokenConfig) { + this.apiTokenConfig = apiTokenConfig; + return this; + } + public Config authc(AuthcDomain authcDomain) { authcDomainMap.put(authcDomain.id, authcDomain); return this; @@ -317,6 +328,10 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params xContentBuilder.field("on_behalf_of", onBehalfOfConfig); } + if (apiTokenConfig != null) { + xContentBuilder.field("api_tokens", apiTokenConfig); + } + if (anonymousAuth || (xffConfig != null)) { xContentBuilder.startObject("http"); xContentBuilder.field("anonymous_auth_enabled", anonymousAuth); diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java index 38b88ba04e..163a195c79 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java @@ -54,6 +54,7 @@ import org.opensearch.security.action.configupdate.ConfigUpdateResponse; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.test.framework.ApiTokenConfig; import org.opensearch.test.framework.AuditConfiguration; import org.opensearch.test.framework.AuthFailureListeners; import org.opensearch.test.framework.AuthzDomain; @@ -664,6 +665,11 @@ public Builder onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) { return this; } + public Builder apiToken(ApiTokenConfig apiTokenConfig) { + testSecurityConfig.apiToken(apiTokenConfig); + return this; + } + public Builder loadConfigurationIntoIndex(boolean loadConfigurationIntoIndex) { this.loadConfigurationIntoIndex = loadConfigurationIntoIndex; return this; diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index f91a26c40f..d7ab41442b 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -92,6 +92,7 @@ import org.opensearch.common.util.BigArrays; import org.opensearch.common.util.PageCacheRecycler; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.action.ActionResponse; import org.opensearch.core.common.io.stream.NamedWriteableRegistry; import org.opensearch.core.index.Index; @@ -133,6 +134,10 @@ import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; import org.opensearch.secure_sm.AccessController; +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; @@ -279,6 +284,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; + private volatile ApiTokenRepository apiTokenRepository; private volatile AdminDNs adminDns; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); @@ -682,6 +688,22 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); + handlers.add( + new ApiTokenAction( + Objects.requireNonNull(threadPool), + cr, + privilegesConfiguration, + settings, + adminDns, + auditLog, + configPath, + principalExtractor, + apiTokenRepository, + cs, + indexNameExpressionResolver, + roleMapper + ) + ); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -741,6 +763,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)); @@ -1211,6 +1234,7 @@ public Collection createComponents( ); this.roleMapper = roleMapper; tokenManager = new SecurityTokenManager(cs, threadPool, userService, roleMapper); + apiTokenRepository = new ApiTokenRepository(localClient, clusterService); PrivilegesConfiguration privilegesConfiguration = new PrivilegesConfiguration( cr, @@ -1224,7 +1248,8 @@ public Collection createComponents( settings, cih::getReasonForUnavailability, irr, - xContentRegistry + xContentRegistry, + apiTokenRepository ); this.privilegesConfiguration = privilegesConfiguration; @@ -1305,7 +1330,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, apiTokenRepository); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); @@ -1356,6 +1381,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(apiTokenRepository); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -2335,6 +2361,14 @@ public void onNodeStarted(DiscoveryNode localNode) { this.localNode.set(localNode); if (!SSLConfig.isSslOnlyMode() && !client && !disabled && !useClusterStateToInitSecurityConfig(settings)) { cr.initOnNodeStart(); + if (apiTokenRepository != null) { + apiTokenRepository.reloadApiTokensFromIndex( + ActionListener.wrap( + unused -> log.debug("API tokens loaded on node start"), + e -> log.warn("Failed to load API tokens on node start", e) + ) + ); + } } // resourceSharingIndexManagementRepository will be null when sec plugin is disabled or is in SSLOnly mode, hence it will not be @@ -2412,8 +2446,13 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); + final SystemIndexDescriptor apiTokenSystemIndexDescriptor = new SystemIndexDescriptor( + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, + "Security API token index" + ); final SystemIndexDescriptor securityIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); systemIndexDescriptors.add(securityIndexDescriptor); + systemIndexDescriptors.add(apiTokenSystemIndexDescriptor); for (String resourceIndex : resourcePluginInfo.getResourceIndices()) { final SystemIndexDescriptor resourceSharingIndexDescriptor = new SystemIndexDescriptor( 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..05202d2406 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -0,0 +1,294 @@ +/* + * 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.List; + +import org.opensearch.core.ParseField; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; +import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public class ApiToken implements ToXContent { + public static final String NAME_FIELD = "name"; + public static final String ID_FIELD = "id"; + 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"; + public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions"; + public static final String EXPIRATION_FIELD = "expiration"; + public static final String TOKEN_HASH_FIELD = "token_hash"; + public static final String REVOKED_AT_FIELD = "revoked_at"; + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "api_token", + false, + args -> new ApiToken( + (String) args[0], + (String) args[1], + args[2] != null ? (List) args[2] : List.of(), + args[3] != null ? (List) args[3] : List.of(), + args[4] != null ? Instant.ofEpochMilli((Long) args[4]) : null, + args[5] != null ? (Long) args[5] : 0L, + args[6] != null ? Instant.ofEpochMilli((Long) args[6]) : null + ) + ); + + static { + PARSER.declareString(constructorArg(), new ParseField(NAME_FIELD)); + PARSER.declareString(constructorArg(), new ParseField(TOKEN_HASH_FIELD)); + PARSER.declareStringArray(optionalConstructorArg(), new ParseField(CLUSTER_PERMISSIONS_FIELD)); + PARSER.declareObjectArray( + optionalConstructorArg(), + (p, c) -> IndexPermission.fromXContent(p), + new ParseField(INDEX_PERMISSIONS_FIELD) + ); + PARSER.declareLong(optionalConstructorArg(), new ParseField(ISSUED_AT_FIELD)); + PARSER.declareLong(optionalConstructorArg(), new ParseField(EXPIRATION_FIELD)); + PARSER.declareLong(optionalConstructorArg(), new ParseField(REVOKED_AT_FIELD)); + } + + private final String name; + private final String tokenHash; + private String id; + private final Instant creationTime; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; + private final Instant revokedAt; + + public ApiToken( + String name, + String tokenHash, + List clusterPermissions, + List indexPermissions, + Instant creationTime, + Long expiration + ) { + this(name, tokenHash, clusterPermissions, indexPermissions, creationTime, expiration, null); + } + + public ApiToken( + String name, + String tokenHash, + List clusterPermissions, + List indexPermissions, + Instant creationTime, + Long expiration, + Instant revokedAt + ) { + this.name = name; + this.tokenHash = tokenHash; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.creationTime = creationTime; + this.expiration = expiration; + this.revokedAt = revokedAt; + } + + 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; + } + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "index_permission", + false, + args -> new IndexPermission( + args[0] != null ? (List) args[0] : List.of(), + args[1] != null ? (List) args[1] : List.of() + ) + ); + + static { + PARSER.declareStringArray(optionalConstructorArg(), new ParseField(INDEX_PATTERN_FIELD)); + PARSER.declareStringArray(optionalConstructorArg(), new ParseField(ALLOWED_ACTIONS_FIELD)); + } + + public static IndexPermission fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + } + + public static ApiToken fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + public String getName() { + return name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTokenHash() { + return tokenHash; + } + + public Long getExpiration() { + return expiration; + } + + public Instant getCreationTime() { + return creationTime; + } + + public List getClusterPermissions() { + return clusterPermissions; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field(NAME_FIELD, name); + xContentBuilder.field(TOKEN_HASH_FIELD, tokenHash); + xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); + xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); + xContentBuilder.field(ISSUED_AT_FIELD, creationTime.toEpochMilli()); + xContentBuilder.field(EXPIRATION_FIELD, expiration); + if (revokedAt != null) { + xContentBuilder.field(REVOKED_AT_FIELD, revokedAt.toEpochMilli()); + } + xContentBuilder.endObject(); + return xContentBuilder; + } + + public List getIndexPermissions() { + return indexPermissions; + } + + public Instant getRevokedAt() { + return revokedAt; + } + + public boolean isRevoked() { + return revokedAt != null; + } + + public static class CreateRequest { + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "create_api_token_request", + false, + args -> new CreateRequest( + (String) args[0], + args[1] != null ? (List) args[1] : List.of(), + args[2] != null ? (List) args[2] : List.of(), + args[3] != null ? (Long) args[3] : Instant.now().toEpochMilli() + java.util.concurrent.TimeUnit.DAYS.toMillis(30) + ) + ); + + static { + PARSER.declareString(constructorArg(), new ParseField(NAME_FIELD)); + PARSER.declareStringArray(optionalConstructorArg(), new ParseField(CLUSTER_PERMISSIONS_FIELD)); + PARSER.declareObjectArray( + optionalConstructorArg(), + (p, c) -> IndexPermission.fromXContent(p), + new ParseField(INDEX_PERMISSIONS_FIELD) + ); + PARSER.declareLong(optionalConstructorArg(), new ParseField(EXPIRATION_FIELD)); + } + + private final String name; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; + + public CreateRequest(String name, List clusterPermissions, List indexPermissions, long expiration) { + this.name = name; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.expiration = expiration; + } + + public static CreateRequest fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + public String getName() { + return name; + } + + public List getClusterPermissions() { + return clusterPermissions; + } + + public List getIndexPermissions() { + return indexPermissions; + } + + public long getExpiration() { + return expiration; + } + } + + public static class DeleteRequest { + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "delete_api_token_request", + false, + args -> new DeleteRequest((String) args[0]) + ); + + static { + PARSER.declareString(constructorArg(), new ParseField(ID_FIELD)); + } + + private final String id; + + public DeleteRequest(String id) { + this.id = id; + } + + public static DeleteRequest fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + public String getId() { + return id; + } + } +} 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..a3ccc4e1b1 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -0,0 +1,287 @@ +/* + * 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.nio.file.Path; +import java.util.List; + +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +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.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +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.PrivilegesConfiguration; +import org.opensearch.security.privileges.RoleMapper; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +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.CLUSTER_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_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.action.apitokens.ApiToken.REVOKED_AT_FIELD; +import static org.opensearch.security.dlic.rest.api.Responses.forbidden; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; + +public class ApiTokenAction extends BaseRestHandler { + private final ApiTokenRepository apiTokenRepository; + protected final Logger log = LogManager.getLogger(this.getClass()); + private final ThreadPool threadPool; + private final ConfigurationRepository configurationRepository; + private final PrivilegesConfiguration privilegesConfiguration; + 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/{id}"), new Route(GET, "/apitokens")) + ); + + public ApiTokenAction( + ThreadPool threadpool, + ConfigurationRepository configurationRepository, + PrivilegesConfiguration privilegesConfiguration, + Settings settings, + AdminDNs adminDns, + AuditLog auditLog, + Path configPath, + PrincipalExtractor principalExtractor, + ApiTokenRepository apiTokenRepository, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver, + RoleMapper roleMapper + ) { + this.apiTokenRepository = apiTokenRepository; + this.threadPool = threadpool; + this.configurationRepository = configurationRepository; + this.privilegesConfiguration = privilegesConfiguration; + this.securityApiDependencies = new SecurityApiDependencies( + adminDns, + configurationRepository, + privilegesConfiguration, + new RestApiPrivilegesEvaluator(settings, adminDns, roleMapper, principalExtractor, configPath, threadPool), + new RestApiAdminPrivilegesEvaluator( + threadPool.getThreadContext(), + privilegesConfiguration, + adminDns, + settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) + ), + auditLog, + settings + ); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; + } + + @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 { + request.param(ApiToken.ID_FIELD); + String authError = authorizeSecurityAccess(request); + if (authError != null) { + return channel -> forbidden(channel, "No permission to access REST API: " + authError); + } + 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"); + }; + } + } + + private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { + return channel -> { + apiTokenRepository.getApiTokens(ActionListener.wrap(tokens -> { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startArray(); + for (ApiToken token : tokens.values()) { + builder.startObject(); + builder.field(ApiToken.ID_FIELD, token.getId()); + 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()); + if (token.getRevokedAt() != null) { + builder.field(REVOKED_AT_FIELD, token.getRevokedAt().toEpochMilli()); + } + 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()); + } + }, exception -> sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()))); + }; + } + + private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { + return channel -> { + try { + final ApiToken.CreateRequest createRequest; + try (XContentParser parser = request.contentOrSourceParamParser()) { + createRequest = ApiToken.CreateRequest.fromXContent(parser); + } + + apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> { + ConfigV7 config = configurationRepository.getConfiguration(CType.CONFIG).getCEntry(CType.CONFIG.name()); + int maxTokens = config.dynamic.api_tokens.getMaxTokens(); + if (tokenCount >= maxTokens) { + sendErrorResponse( + channel, + RestStatus.TOO_MANY_REQUESTS, + "Maximum limit of " + maxTokens + " API tokens reached. Please delete existing tokens before creating new ones." + ); + return; + } + apiTokenRepository.createApiToken( + createRequest.getName(), + createRequest.getClusterPermissions(), + createRequest.getIndexPermissions(), + createRequest.getExpiration(), + wrapWithCacheRefresh(ActionListener.wrap(created -> { + apiTokenRepository.notifyAboutChanges(); + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field(ApiToken.ID_FIELD, created.id()).field("token", created.token()).endObject(); + 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 RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { + return channel -> { + try { + String id = request.param("id"); + apiTokenRepository.revokeApiToken(id, wrapWithCacheRefresh(ActionListener.wrap(ignored -> { + apiTokenRepository.notifyAboutChanges(); + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("message", "Token " + id + " revoked successfully.").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) { + RestStatus status = exception instanceof ApiTokenException ? RestStatus.NOT_FOUND : RestStatus.INTERNAL_SERVER_ERROR; + sendErrorResponse(channel, status, exception.getMessage()); + } + }; + } + + private ActionListener wrapWithCacheRefresh(ActionListener listener, NodeClient client) { + return ActionListener.wrap(response -> { + try { + client.execute( + ApiTokenUpdateAction.INSTANCE, + new ApiTokenUpdateRequest(), + ActionListener.wrap( + updateResponse -> listener.onResponse(response), + exception -> listener.onFailure(new ApiTokenException("Failed to update API token", exception)) + ) + ); + } catch (Exception e) { + listener.onFailure(new ApiTokenException("Failed to update API token", e)); + } + }, listener::onFailure); + } + + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + channel.sendResponse(new BytesRestResponse(status, builder)); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + + protected String authorizeSecurityAccess(RestRequest request) throws IOException { + if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) + || securityApiDependencies.restApiPrivilegesEvaluator().checkAccessPermissions(request, Endpoint.APITOKENS) == null)) { + return "User does not have required security API access"; + } + return null; + } +} 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..fb63fac630 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -0,0 +1,137 @@ +/* + * 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.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.DocWriteResponse; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.cluster.service.ClusterService; +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.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.transport.client.Client; + +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 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) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE); + + client.index(request, ActionListener.wrap(indexResponse -> { + LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + listener.onResponse(indexResponse.getId()); + }, exception -> { + LOGGER.error(exception.getMessage()); + LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + listener.onFailure(exception); + })); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void revokeToken(String id, ActionListener listener) { + Map updateFields = Map.of(ApiToken.REVOKED_AT_FIELD, Instant.now().toEpochMilli()); + UpdateRequest request = new UpdateRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, id).doc(updateFields) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE); + client.update(request, ActionListener.wrap(response -> { + if (DocWriteResponse.Result.NOT_FOUND.equals(response.getResult())) { + listener.onFailure(new ApiTokenException("No token found with id " + id)); + } else { + listener.onResponse(null); + } + }, listener::onFailure)); + } + + public void getTokenMetadatas(ActionListener> listener) { + try { + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder().size(10_000)); + + 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); + token.setId(hit.getId()); + tokens.put(token.getTokenHash(), token); + } + } + listener.onResponse(tokens); + } catch (IOException e) { + listener.onFailure(e); + } + }, listener::onFailure)); + } catch (Exception e) { + listener.onFailure(e); + } + } + + public Boolean apiTokenIndexExists() { + return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + } + + public void createApiTokenIndexIfAbsent(ActionListener listener) { + if (!apiTokenIndexExists()) { + 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, listener); + } else { + listener.onResponse(null); + } + } + +} 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..5b82b9565a --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.ExceptionsHelper; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.configuration.TokenListener; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.user.User; +import org.opensearch.transport.client.Client; + +import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; + +public class ApiTokenRepository { + public static final String TOKEN_PREFIX = "os_"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final ApiTokenIndexHandler apiTokenIndexHandler; + private final List tokenListener = new ArrayList<>(); + private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); + + private final Map tokenHashToRole = new ConcurrentHashMap<>(); + private final Map tokenHashToExpiration = new ConcurrentHashMap<>(); + + public record TokenMetadata(RoleV7 role, long expiration) { + public boolean isExpired() { + return expiration > 0 && Instant.now().toEpochMilli() > expiration; + } + } + + public static String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private static String generateToken() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public void reloadApiTokensFromIndex(ActionListener listener) { + apiTokenIndexHandler.getTokenMetadatas(new ActionListener>() { + @Override + public void onResponse(Map tokenMetadatas) { + tokenHashToRole.keySet().removeIf(hash -> !tokenMetadatas.containsKey(hash)); + tokenHashToExpiration.keySet().removeIf(hash -> !tokenMetadatas.containsKey(hash)); + tokenMetadatas.forEach((hash, tokenMetadata) -> { + if (tokenMetadata.isRevoked()) { + tokenHashToRole.remove(hash); + tokenHashToExpiration.remove(hash); + return; + } + tokenHashToRole.put(hash, buildRole(tokenMetadata)); + tokenHashToExpiration.put(hash, tokenMetadata.getExpiration()); + }); + listener.onResponse(null); + } + + @Override + public void onFailure(Exception e) { + if (ExceptionsHelper.unwrapCause(e) instanceof IndexNotFoundException) { + log.debug("API tokens index does not exist yet, skipping reload"); + listener.onResponse(null); + return; + } + listener.onFailure(new OpenSearchSecurityException("Received error while reloading API tokens metadata from index", e)); + } + }); + } + + private static RoleV7 buildRole(ApiToken tokenMetadata) { + RoleV7 role = new RoleV7(); + role.setCluster_permissions(tokenMetadata.getClusterPermissions()); + List indexPerms = new ArrayList<>(); + for (ApiToken.IndexPermission ip : tokenMetadata.getIndexPermissions()) { + RoleV7.Index indexPerm = new RoleV7.Index(); + indexPerm.setIndex_patterns(ip.getIndexPatterns()); + indexPerm.setAllowed_actions(ip.getAllowedActions()); + indexPerms.add(indexPerm); + } + role.setIndex_permissions(indexPerms); + return role; + } + + public synchronized void subscribeOnChange(TokenListener listener) { + tokenListener.add(listener); + } + + public synchronized void notifyAboutChanges() { + for (TokenListener listener : tokenListener) { + try { + log.debug("Notify {} listener about change", listener); + listener.onChange(); + } catch (Exception e) { + log.error("{} listener errored: " + e, listener, e); + throw ExceptionsHelper.convertToOpenSearchException(e); + } + } + } + + public RoleV7 getApiTokenPermissionsForUser(User user) { + String name = user.getName(); + if (name.startsWith(API_TOKEN_USER_PREFIX)) { + String hash = name.substring(API_TOKEN_USER_PREFIX.length()); + if (isValidToken(hash)) { + return getPermissionsForHash(hash); + } + } + return new RoleV7(); + } + + public RoleV7 getPermissionsForHash(String hash) { + return tokenHashToRole.get(hash); + } + + public TokenMetadata getTokenMetadata(String hash) { + RoleV7 role = tokenHashToRole.get(hash); + Long expiration = tokenHashToExpiration.get(hash); + if (role == null || expiration == null) { + return null; + } + return new TokenMetadata(role, expiration); + } + + public boolean isValidToken(String hash) { + return tokenHashToRole.containsKey(hash); + } + + public void forEachToken(BiConsumer consumer) { + tokenHashToRole.forEach((hash, role) -> consumer.accept(API_TOKEN_USER_PREFIX + hash, role)); + } + + @VisibleForTesting + Map getJtis() { + return tokenHashToRole; + } + + public ApiTokenRepository(Client client, ClusterService clusterService) { + apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler) { + this.apiTokenIndexHandler = apiTokenIndexHandler; + } + + @VisibleForTesting + static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler) { + return new ApiTokenRepository(apiTokenIndexHandler); + } + + public record TokenCreated(String id, String token) { + } + + public void createApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Long expiration, + ActionListener listener + ) { + String plaintext = generateToken(); + String hash = hashToken(plaintext); + ApiToken apiToken = new ApiToken(name, hash, clusterPermissions, indexPermissions, Instant.now(), expiration); + apiTokenIndexHandler.createApiTokenIndexIfAbsent(ActionListener.wrap(() -> { + apiTokenIndexHandler.indexTokenMetadata(apiToken, ActionListener.wrap(id -> { + tokenHashToRole.put(hash, buildRole(apiToken)); + tokenHashToExpiration.put(hash, expiration); + listener.onResponse(new TokenCreated(id, plaintext)); + }, listener::onFailure)); + })); + } + + public void revokeApiToken(String id, ActionListener listener) throws ApiTokenException, IndexNotFoundException { + apiTokenIndexHandler.revokeToken(id, listener); + } + + public void getApiTokens(ActionListener> listener) { + apiTokenIndexHandler.getTokenMetadatas(ActionListener.wrap(listener::onResponse, e -> { + if (ExceptionsHelper.unwrapCause(e) instanceof IndexNotFoundException) { + listener.onResponse(Map.of()); + } else { + listener.onFailure(e); + } + })); + } + + public void getTokenCount(ActionListener listener) { + getApiTokens(ActionListener.wrap(tokens -> listener.onResponse((long) tokens.size()), listener::onFailure)); + } +} 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..06867804e7 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,47 @@ +/* + * 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; + +public class ApiTokenUpdateResponse extends BaseNodesResponse { + + 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); + } + +} 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..d3b35526fb --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,107 @@ +/* + * 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.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.util.TransportNodesAsyncAction; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAsyncAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenRepository apiTokenRepository; + + @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, + ThreadPool.Names.SAME, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenRepository = apiTokenRepository; + } + + 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 void nodeOperation(final NodeApiTokenUpdateRequest request, ActionListener listener) { + apiTokenRepository.reloadApiTokensFromIndex(ActionListener.wrap(unused -> { + apiTokenRepository.notifyAboutChanges(); + listener.onResponse(new ApiTokenUpdateNodeResponse(clusterService.localNode())); + }, listener::onFailure)); + } +} 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 748fdec2c0..9dac4c12ae 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -72,6 +72,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.security.user.UserFactory; import org.opensearch.tasks.Task; @@ -93,6 +94,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(); @@ -127,6 +129,12 @@ protected AbstractAuditLog( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_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; this.userFactory = userFactory; } @@ -481,7 +489,7 @@ public void logDocumentRead(String index, String id, ShardId shardId, Map map = fieldNameValues.entrySet() .stream() @@ -548,7 +556,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; @@ -578,7 +586,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index // originalSource is empty originalSource = "{}"; } - if (securityIndex.equals(shardId.getIndexName())) { + if (securityIndicesMatcher.test(shardId.getIndexName())) { if (originalSource == null) { try ( XContentParser parser = XContentHelper.createParser( @@ -638,7 +646,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } if (!complianceConfig.shouldLogWriteMetadataOnly() && !complianceConfig.shouldLogDiffsForWrite()) { - 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/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/compliance/ComplianceConfig.java b/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java index da54837719..71413e0dde 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/configuration/TokenListener.java b/src/main/java/org/opensearch/security/configuration/TokenListener.java new file mode 100644 index 0000000000..febfa9eb0e --- /dev/null +++ b/src/main/java/org/opensearch/security/configuration/TokenListener.java @@ -0,0 +1,39 @@ +/* + * Copyright 2015-2018 _floragunn_ GmbH + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * 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.configuration; + +/** + * Callback function on change particular configuration + */ +@FunctionalInterface +public interface TokenListener { + + /** + * This is triggered when the token configuration changes. + */ + void onChange(); +} 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 fc0f6dcdae..08e5a2fbc1 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 @@ -31,6 +31,7 @@ public enum Endpoint { NODESDN, SSL, RESOURCE_SHARING, + APITOKENS, VIEW_VERSION, ROLLBACK_VERSION; } 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 9045fcbcd3..1264fb2ebb 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 @@ -73,6 +73,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.VIEW_VERSION, action -> buildEndpointPermission(Endpoint.VIEW_VERSION)) .put(Endpoint.ROLLBACK_VERSION, action -> buildEndpointPermission(Endpoint.ROLLBACK_VERSION)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) 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..fba14a5575 --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,135 @@ +/* + * 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.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.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 static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.action.apitokens.ApiTokenRepository.TOKEN_PREFIX; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + private static final Pattern API_KEY_HEADER = Pattern.compile("^\\s*ApiKey\\s.*", Pattern.CASE_INSENSITIVE); + private static final String API_KEY_PREFIX = "apikey "; + + public static final String API_TOKEN_USER_PREFIX = "token:"; + + public Logger log = LogManager.getLogger(this.getClass()); + + private final boolean apiTokenEnabled; + private final ApiTokenRepository apiTokenRepository; + + public ApiTokenAuthenticator(Settings settings, String clusterName, ApiTokenRepository apiTokenRepository) { + this.apiTokenEnabled = Boolean.parseBoolean(settings.get("enabled", "true")); + this.apiTokenRepository = apiTokenRepository; + } + + @Override + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String token = extractTokenFromHeader(request); + if (token == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + if (!token.startsWith(TOKEN_PREFIX)) { + log.debug("Token does not have expected prefix"); + return null; + } + + String hash = ApiTokenRepository.hashToken(token); + if (!apiTokenRepository.isValidToken(hash)) { + log.error("Api token is not valid"); + return null; + } + + ApiTokenRepository.TokenMetadata metadata = apiTokenRepository.getTokenMetadata(hash); + if (metadata == null) { + log.error("Api token metadata not found"); + return null; + } + + if (metadata.isExpired()) { + log.debug("Api token is expired"); + return null; + } + + return new AuthCredentials(API_TOKEN_USER_PREFIX + hash, java.util.List.of(), "").markComplete(); + } + + private String extractTokenFromHeader(SecurityRequest request) { + String header = request.header(HttpHeaders.AUTHORIZATION); + if (header == null || header.isEmpty()) { + log.debug("No token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + if (!API_KEY_HEADER.matcher(header).matches()) { + log.debug("No ApiKey scheme found in header"); + return null; + } + return header.substring(header.toLowerCase().indexOf(API_KEY_PREFIX) + API_KEY_PREFIX.length()); + } + + 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"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java b/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java index 8fcaf55ca0..e8904ed8b5 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java @@ -23,9 +23,11 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.configuration.PrivilegesInterceptorImpl; +import org.opensearch.security.privileges.actionlevel.SubjectBasedActionPrivileges; import org.opensearch.security.privileges.dlsfls.DlsFlsProcessedConfig; import org.opensearch.security.privileges.dlsfls.FieldMasking; import org.opensearch.security.resolver.IndexResolverReplacer; @@ -70,6 +72,8 @@ public class PrivilegesConfiguration { private final AtomicReference dlsFlsProcessedConfig = new AtomicReference<>(); private final PrivilegesInterceptorImpl privilegesInterceptor; + private ApiTokenRepository apiTokenRepository; + private final Map tokenIdToActionPrivileges = new HashMap<>(); /** * The pure static action groups should be ONLY used by action privileges for plugins; only those cannot and should @@ -94,7 +98,8 @@ public PrivilegesConfiguration( Settings settings, Supplier unavailablityReasonSupplier, IndexResolverReplacer indexResolverReplacer, - NamedXContentRegistry namedXContentRegistry + NamedXContentRegistry namedXContentRegistry, + ApiTokenRepository apiTokenRepository ) { this.fieldMaskingConfig = FieldMasking.Config.fromSettings(settings); @@ -110,6 +115,19 @@ public PrivilegesConfiguration( this.staticActionGroups = buildStaticActionGroups(); this.namedXContentRegistry = namedXContentRegistry; + if (apiTokenRepository != null) { + apiTokenRepository.subscribeOnChange(() -> { + SecurityDynamicConfiguration actionGroupsConfiguration = configurationRepository.getConfiguration( + CType.ACTIONGROUPS + ); + FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguration.withStaticConfig()); + tokenIdToActionPrivileges.clear(); + apiTokenRepository.forEachToken( + (jti, role) -> tokenIdToActionPrivileges.put(jti, new SubjectBasedActionPrivileges(role, flattenedActionGroups)) + ); + }); + } + if (configurationRepository != null) { configurationRepository.subscribeOnChange(configMap -> { SecurityDynamicConfiguration actionGroupsConfiguration = configurationRepository.getConfiguration( @@ -156,7 +174,8 @@ public PrivilegesConfiguration( staticActionGroups, newCompiledRoles, generalConfiguration, - pluginIdToRolePrivileges + pluginIdToRolePrivileges, + tokenIdToActionPrivileges ) ); if (oldInstance != null) { @@ -197,6 +216,8 @@ public PrivilegesConfiguration( if (clusterService != null) { clusterService.addListener(event -> { this.privilegesEvaluator.get().updateClusterStateMetadata(clusterService); }); } + + this.apiTokenRepository = apiTokenRepository; } /** diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorImpl.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorImpl.java index b14c5aeff3..64531ceea5 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorImpl.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorImpl.java @@ -146,6 +146,7 @@ public class PrivilegesEvaluatorImpl implements PrivilegesEvaluator { private final Settings settings; private final AtomicReference actionPrivileges = new AtomicReference<>(); private final ImmutableMap pluginIdToActionPrivileges; + private final Map tokenIdToActionPrivileges; private final RoleMapper roleMapper; private volatile boolean dnfofEnabled = false; @@ -167,7 +168,8 @@ public PrivilegesEvaluatorImpl( FlattenedActionGroups staticActionGroups, CompiledRoles rolesConfiguration, ConfigV7 generalConfiguration, - Map pluginIdToRolePrivileges + Map pluginIdToRolePrivileges, + Map tokenIdToActionPrivileges ) { super(); @@ -197,6 +199,7 @@ public PrivilegesEvaluatorImpl( termsAggregationEvaluator = new TermsAggregationEvaluator(); pitPrivilegesEvaluator = new PitPrivilegesEvaluator(); + this.tokenIdToActionPrivileges = tokenIdToActionPrivileges; this.pluginIdToActionPrivileges = createActionPrivileges(pluginIdToRolePrivileges, staticActionGroups); this.updateConfiguration(actionGroups, rolesConfiguration, generalConfiguration); } @@ -264,6 +267,9 @@ public PrivilegesEvaluationContext createContext( if (user.isPluginUser()) { mappedRoles = ImmutableSet.of(); actionPrivileges = this.pluginIdToActionPrivileges.getOrDefault(user.getName(), ActionPrivileges.EMPTY); + } else if (user.isApiTokenRequest()) { + mappedRoles = ImmutableSet.of(); + actionPrivileges = this.tokenIdToActionPrivileges.getOrDefault(user.getName(), ActionPrivileges.EMPTY); } else { mappedRoles = this.roleMapper.map(user, caller); actionPrivileges = this.actionPrivileges.get(); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index f89c215899..a0cbf81072 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -42,6 +42,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; 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; @@ -128,6 +129,7 @@ public final static SecurityDynamicConfiguration addStatics(SecurityDynam private final ClusterInfoHolder cih; private final ThreadPool threadPool; private final Client client; + private final ApiTokenRepository apiTokenRepository; SecurityDynamicConfiguration config; @@ -138,7 +140,8 @@ public DynamicConfigFactory( Client client, ThreadPool threadPool, ClusterInfoHolder cih, - PasswordHasher passwordHasher + PasswordHasher passwordHasher, + ApiTokenRepository apiTokenRepository ) { super(); this.cr = cr; @@ -148,6 +151,7 @@ public DynamicConfigFactory( this.iab = new InternalAuthenticationBackend(passwordHasher); this.threadPool = threadPool; this.client = client; + this.apiTokenRepository = apiTokenRepository; if (opensearchSettings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES, true)) { try { @@ -252,7 +256,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); // notify subscribers diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index fb24397a04..9c8f0266dd 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -47,6 +47,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; import org.opensearch.secure_sm.AccessController; +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; @@ -57,6 +58,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; @@ -83,13 +85,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; @@ -97,6 +101,7 @@ public DynamicConfigModelV7( this.configPath = configPath; this.iab = iab; this.cih = cih; + this.apiTokenRepository = apiTokenRepository; buildAAA(); } @@ -363,6 +368,26 @@ 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 + */ + if (Boolean.TRUE.equals(config.dynamic.api_tokens.getEnabled())) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator( + Settings.builder().loadFromSource(config.dynamic.api_tokens.configAsJson(), XContentType.JSON).build(), + 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 def5247590..9d5534ee0e 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,42 @@ public String toString() { } } + public static class ApiTokenSettings { + @JsonProperty("enabled") + private Boolean enabled = Boolean.FALSE; + @JsonProperty("max_tokens") + private int maxTokens = 100; + + @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 apiTokensEnabled) { + this.enabled = apiTokensEnabled; + } + + public int getMaxTokens() { + return Math.min(maxTokens, 1000); + } + + public void setMaxTokens(int maxTokens) { + this.maxTokens = maxTokens; + } + + @Override + public String toString() { + return "ApiTokenSettings [ enabled=" + enabled + ", max_tokens=" + maxTokens + "]"; + } + + } + } 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/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 25cee4539d..bc446cc6c0 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -423,6 +423,7 @@ 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"; // Resource sharing feature-flag public static final String OPENSEARCH_RESOURCE_SHARING_ENABLED = "plugins.security.experimental.resource_sharing.enabled"; public static final boolean OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT = false; diff --git a/src/main/java/org/opensearch/security/user/User.java b/src/main/java/org/opensearch/security/user/User.java index 4605efe674..cacd4399d7 100644 --- a/src/main/java/org/opensearch/security/user/User.java +++ b/src/main/java/org/opensearch/security/user/User.java @@ -315,6 +315,13 @@ public boolean isPluginUser() { return name != null && name.startsWith("plugin:"); } + /** + * @return true if the request is from an API token, otherwise false + */ + public boolean isApiTokenRequest() { + return name != null && name.startsWith("token:"); + } + /** * Returns a String containing serialized form of this User object. Never returns null. */ diff --git a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java index 7bbe634c6c..ed91973c1c 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -21,6 +21,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/obo/token"; private static final String ON_BEHALF_OF_SUFFIX_DEPRECATED = "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) { @@ -30,6 +31,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques case ON_BEHALF_OF_SUFFIX: case ON_BEHALF_OF_SUFFIX_DEPRECATED: 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/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..990b9766f9 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,137 @@ +/* + * 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.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 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.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 plainToken = "os_validtoken123"; + private final String tokenHash = ApiTokenRepository.hashToken(plainToken); + + @Before + public void setUp() { + Settings settings = Settings.builder().put("enabled", "true").build(); + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + threadContext = new ThreadContext(Settings.EMPTY); + } + + @Test + public void testExtractCredentialsPassWhenTokenInCache() { + when(apiTokenRepository.isValidToken(tokenHash)).thenReturn(true); + when(apiTokenRepository.getTokenMetadata(tokenHash)).thenReturn( + new ApiTokenRepository.TokenMetadata(new org.opensearch.security.securityconf.impl.v7.RoleV7(), Long.MAX_VALUE) + ); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("ApiKey " + plainToken); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNotNull("Should return credentials when token is valid", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenNotInCache() { + when(apiTokenRepository.isValidToken(tokenHash)).thenReturn(false); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("ApiKey " + plainToken); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when token is not in cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + when(apiTokenRepository.isValidToken(tokenHash)).thenReturn(true); + when(apiTokenRepository.getTokenMetadata(tokenHash)).thenReturn( + new ApiTokenRepository.TokenMetadata(new org.opensearch.security.securityconf.impl.v7.RoleV7(), 1L) + ); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("ApiKey " + plainToken); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when token is expired", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenMissingPrefix() { + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("ApiKey notanosprefixedtoken"); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when token does not have os_ prefix", ac); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("ApiKey " + plainToken); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when accessing restricted endpoint", ac); + verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); + } + + @Test + public void testAuthenticatorNotEnabled() { + Settings settings = Settings.builder().put("enabled", "false").build(); + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + + SecurityRequest request = mock(SecurityRequest.class); + + AuthCredentials ac = authenticator.extractCredentials(request, new ThreadContext(Settings.EMPTY)); + + assertNull("Should return null when authenticator is disabled", 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 new file mode 100644 index 0000000000..033275b0a5 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -0,0 +1,300 @@ +/* + * 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.DocWriteResponse; +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.update.UpdateRequest; +import org.opensearch.action.update.UpdateResponse; +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.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +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; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +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(ActionListener.wrap(() -> { + 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(ActionListener.wrap(() -> { + verifyNoInteractions(indicesAdminClient); + })); + } + + @Test + public void testRevokeApiTokenCallsUpdateWithSuppliedId() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + String tokenId = "doc-id-123"; + + TestActionListener listener = new TestActionListener<>(); + + doAnswer(invocation -> { + ActionListener parentListener = invocation.getArgument(1); + UpdateResponse response = mock(UpdateResponse.class); + when(response.getResult()).thenReturn(DocWriteResponse.Result.UPDATED); + parentListener.onResponse(response); + return null; + }).when(client).update(any(UpdateRequest.class), any(ActionListener.class)); + + indexHandler.revokeToken(tokenId, listener); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateRequest.class); + verify(client).update(captor.capture(), any(ActionListener.class)); + listener.assertSuccess(); + + UpdateRequest capturedRequest = captor.getValue(); + assertThat(capturedRequest.index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + assertThat(capturedRequest.id(), equalTo(tokenId)); + } + + @Test + public void testRevokeTokenThrowsExceptionWhenTokenNotFound() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + UpdateResponse response = mock(UpdateResponse.class); + when(response.getResult()).thenReturn(DocWriteResponse.Result.NOT_FOUND); + listener.onResponse(response); + return null; + }).when(client).update(any(UpdateRequest.class), any(ActionListener.class)); + + String tokenId = "nonexistent-id"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.revokeToken(tokenId, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("No token found with id " + tokenId)); + } + + @Test + public void testRevokeTokenSucceedsWhenDocumentIsUpdated() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + UpdateResponse response = mock(UpdateResponse.class); + when(response.getResult()).thenReturn(DocWriteResponse.Result.UPDATED); + listener.onResponse(response); + return null; + }).when(client).update(any(UpdateRequest.class), any(ActionListener.class)); + + String tokenId = "existing-id"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.revokeToken(tokenId, listener); + + listener.assertSuccess(); + } + + @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", + ApiTokenRepository.hashToken("os_test"), + clusterPermissions, + indexPermissions, + Instant.now(), + Long.MAX_VALUE + ); + + // Mock the index response + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + IndexResponse mockResponse = mock(IndexResponse.class); + when(mockResponse.getId()).thenReturn("test-doc-id"); + listener.onResponse(mockResponse); + return null; + }).when(client).index(any(IndexRequest.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + indexHandler.indexTokenMetadata(token, listener); + + String id = listener.assertSuccess(); + assertThat(id, equalTo("test-doc-id")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(client).index(requestCaptor.capture(), any(ActionListener.class)); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + + String source = capturedRequest.source().utf8ToString(); + assertThat(source, containsString("test-token-description")); + 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", + ApiTokenRepository.hashToken("os_token1"), + Arrays.asList("cluster:admin/something"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index1-*"), + Arrays.asList("read") + )), + Instant.now(), + Long.MAX_VALUE + ); + + // Second token + ApiToken token2 = new ApiToken( + "token2-description", + ApiTokenRepository.hashToken("os_token2"), + Arrays.asList("cluster:admin/other"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index2-*"), + Arrays.asList("write") + )), + Instant.now(), + Long.MAX_VALUE + ); + + // 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); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(searchResponse); + return null; + }).when(client).search(any(SearchRequest.class), any(ActionListener.class)); + + TestActionListener> listener = new TestActionListener<>(); + indexHandler.getTokenMetadatas(listener); + + Map resultTokens = listener.assertSuccess(); + assertThat(resultTokens.size(), equalTo(2)); + assertThat(resultTokens.containsKey(ApiTokenRepository.hashToken("os_token1")), is(true)); + assertThat(resultTokens.containsKey(ApiTokenRepository.hashToken("os_token2")), is(true)); + + ApiToken resultToken1 = resultTokens.get(ApiTokenRepository.hashToken("os_token1")); + assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); + + ApiToken resultToken2 = resultTokens.get(ApiTokenRepository.hashToken("os_token2")); + 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..7b6bd956e6 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -0,0 +1,408 @@ +/* + * 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.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.securityconf.impl.v7.RoleV7; +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.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@SuppressWarnings("unchecked") +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenRepositoryTest { + private static final String TOKEN_ALPHA = "os_alpha"; + private static final String TOKEN_BETA = "os_beta"; + private static final String TOKEN_FRESH = "os_fresh"; + private static final String TOKEN_STALE = "os_stale"; + private static final String TOKEN_ONE = "os_one"; + private static final String TOKEN_TWO = "os_two"; + private static final String TOKEN_THREE = "os_three"; + private static final String TOKEN_TEST = "os_test"; + private static final String TOKEN_EXISTS = "os_exists"; + + private static final String HASH_ALPHA = ApiTokenRepository.hashToken(TOKEN_ALPHA); + private static final String HASH_BETA = ApiTokenRepository.hashToken(TOKEN_BETA); + private static final String HASH_FRESH = ApiTokenRepository.hashToken(TOKEN_FRESH); + private static final String HASH_STALE = ApiTokenRepository.hashToken(TOKEN_STALE); + private static final String HASH_ONE = ApiTokenRepository.hashToken(TOKEN_ONE); + private static final String HASH_TWO = ApiTokenRepository.hashToken(TOKEN_TWO); + private static final String HASH_THREE = ApiTokenRepository.hashToken(TOKEN_THREE); + private static final String HASH_TEST = ApiTokenRepository.hashToken(TOKEN_TEST); + private static final String HASH_EXISTS = ApiTokenRepository.hashToken(TOKEN_EXISTS); + @Mock + private ApiTokenIndexHandler apiTokenIndexHandler; + private ApiTokenRepository repository; + + @Before + public void setUp() { + apiTokenIndexHandler = mock(ApiTokenIndexHandler.class); + repository = ApiTokenRepository.forTest(apiTokenIndexHandler); + } + + @Test + public void testRevokeApiToken() throws ApiTokenException { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).revokeToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.revokeApiToken(tokenName, listener); + + listener.assertSuccess(); + verify(apiTokenIndexHandler).revokeToken(eq(tokenName), any(ActionListener.class)); + } + + @Test + public void testGetApiTokenPermissionsForUser() throws ApiTokenException { + User derek = new User("derek"); + User apiTokenNotExists = new User("token:notexists"); + User apiTokenExists = new User("token:" + HASH_EXISTS); + RoleV7 all = new RoleV7(); + RoleV7.Index allIndices = new RoleV7.Index(); + allIndices.setAllowed_actions(List.of("*")); + allIndices.setIndex_patterns(List.of("*")); + all.setCluster_permissions(List.of("cluster_all")); + all.setIndex_permissions(List.of(allIndices)); + repository.getJtis().put(HASH_EXISTS, all); + + RoleV7 permissionsForDerek = repository.getApiTokenPermissionsForUser(derek); + assertEquals(List.of(), permissionsForDerek.getCluster_permissions()); + assertEquals(List.of(), permissionsForDerek.getIndex_permissions()); + + RoleV7 permissionsForApiTokenNotExists = repository.getApiTokenPermissionsForUser(apiTokenNotExists); + assertEquals(List.of(), permissionsForApiTokenNotExists.getCluster_permissions()); + assertEquals(List.of(), permissionsForApiTokenNotExists.getIndex_permissions()); + + RoleV7 permissionsForApiTokenExists = repository.getApiTokenPermissionsForUser(apiTokenExists); + assertEquals(List.of("cluster_all"), permissionsForApiTokenExists.getCluster_permissions()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndex_permissions().get(0).getAllowed_actions()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndex_permissions().get(0).getIndex_patterns()); + } + + @Test + public void testGetApiTokens() throws IndexNotFoundException { + Map expectedTokens = new HashMap<>(); + expectedTokens.put( + HASH_TEST, + new ApiToken("token1", HASH_TEST, Arrays.asList("perm1"), Arrays.asList(), Instant.now(), Long.MAX_VALUE) + ); + + 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(any(ActionListener.class)); + } + + @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; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse("test-doc-id"); + return null; + }).when(apiTokenIndexHandler).indexTokenMetadata(any(ApiToken.class), any(ActionListener.class)); + + doAnswer(invocation -> { + ActionListener l = invocation.getArgument(0); + l.onResponse(null); + return null; + }).when(apiTokenIndexHandler).createApiTokenIndexIfAbsent(any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration, listener); + ApiTokenRepository.TokenCreated created = listener.assertSuccess(); + + assertTrue("Token should start with os_ prefix", created.token().startsWith(ApiTokenRepository.TOKEN_PREFIX)); + assertThat(created.id(), equalTo("test-doc-id")); + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(any()); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) + && token.getTokenHash().equals(ApiTokenRepository.hashToken(created.token())) + ), + any(ActionListener.class) + ); + } + + @Test + public void testRevokeApiTokenThrowsApiTokenException() { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onFailure(new ApiTokenException("Token not found")); + return null; + }).when(apiTokenIndexHandler).revokeToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.revokeApiToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("Token not found")); + } + + @Test + public void testJtisOperations() { + String jti = "testJti"; + RoleV7 testRole = new RoleV7(); + RoleV7.Index none = new RoleV7.Index(); + none.setAllowed_actions(List.of("")); + none.setIndex_patterns(List.of("")); + testRole.setCluster_permissions(List.of("read")); + testRole.setIndex_permissions(List.of(none)); + + repository.getJtis().put(jti, testRole); + assertEquals("Should retrieve correct permissions", testRole, repository.getJtis().get(jti)); + + repository.getJtis().remove(jti); + assertNull("Should return null after removal", repository.getJtis().get(jti)); + } + + @Test + public void testClearJtis() { + RoleV7 testRole = new RoleV7(); + RoleV7.Index none = new RoleV7.Index(); + none.setAllowed_actions(List.of("")); + none.setIndex_patterns(List.of("")); + testRole.setCluster_permissions(List.of("read")); + testRole.setIndex_permissions(List.of(none)); + repository.getJtis().put("testJti", testRole); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(Collections.emptyMap()); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + repository.reloadApiTokensFromIndex(ActionListener.wrap(() -> { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty())); + })); + } + + @Test + public void testReloadApiTokensFromIndexWithMultipleTokens() { + Map tokens = Map.of( + HASH_ALPHA, + new ApiToken("alpha", HASH_ALPHA, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE), + HASH_BETA, + new ApiToken( + "beta", + HASH_BETA, + List.of("cluster:admin"), + List.of(new ApiToken.IndexPermission(List.of("logs-*"), List.of("read"))), + Instant.now(), + Long.MAX_VALUE + ) + ); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(tokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.reloadApiTokensFromIndex(listener); + + listener.assertSuccess(); + assertEquals(2, repository.getJtis().size()); + assertTrue(repository.getJtis().containsKey(HASH_ALPHA)); + assertTrue(repository.getJtis().containsKey(HASH_BETA)); + assertEquals(List.of("cluster:monitor"), repository.getJtis().get(HASH_ALPHA).getCluster_permissions()); + assertEquals(List.of("cluster:admin"), repository.getJtis().get(HASH_BETA).getCluster_permissions()); + assertEquals(1, repository.getJtis().get(HASH_BETA).getIndex_permissions().size()); + } + + @Test + public void testReloadApiTokensFromIndexExcludesRevokedTokens() { + Map tokens = Map.of( + HASH_ALPHA, + new ApiToken("active", HASH_ALPHA, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE, null), + HASH_BETA, + new ApiToken("revoked", HASH_BETA, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE, Instant.now()) + ); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(tokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.reloadApiTokensFromIndex(listener); + + listener.assertSuccess(); + assertTrue("Active token should be in cache", repository.getJtis().containsKey(HASH_ALPHA)); + assertFalse("Revoked token should not be in cache", repository.getJtis().containsKey(HASH_BETA)); + } + + @Test + public void testReloadApiTokensFromIndexEvictsTokenThatBecomesRevoked() { + // Seed the cache with a token that is about to be revoked + RoleV7 role = new RoleV7(); + role.setCluster_permissions(List.of("cluster:monitor")); + repository.getJtis().put(HASH_BETA, role); + + // Next reload returns the same token but now with revoked_at set + Map tokens = Map.of( + HASH_BETA, + new ApiToken("revoked", HASH_BETA, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE, Instant.now()) + ); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(tokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.reloadApiTokensFromIndex(listener); + + listener.assertSuccess(); + assertFalse("Token should be evicted from cache after revocation", repository.getJtis().containsKey(HASH_BETA)); + assertFalse("Token should be evicted from expiration cache after revocation", repository.isValidToken(HASH_BETA)); + } + + @Test + public void testReloadApiTokensFromIndexRemovesStaleTokens() { + RoleV7 staleRole = new RoleV7(); + staleRole.setCluster_permissions(List.of("cluster:monitor")); + repository.getJtis().put(HASH_STALE, staleRole); + + Map freshTokens = Map.of( + HASH_FRESH, + new ApiToken("fresh", HASH_FRESH, List.of("cluster:admin"), List.of(), Instant.now(), Long.MAX_VALUE) + ); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(freshTokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.reloadApiTokensFromIndex(listener); + + listener.assertSuccess(); + assertFalse("Stale token should be removed", repository.getJtis().containsKey(HASH_STALE)); + assertTrue("Fresh token should be present", repository.getJtis().containsKey(HASH_FRESH)); + } + + @Test + public void testReloadApiTokensFromIndexOnlyCallsListenerOnce() { + Map tokens = Map.of( + HASH_ONE, + new ApiToken("one", HASH_ONE, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE), + HASH_TWO, + new ApiToken("two", HASH_TWO, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE), + HASH_THREE, + new ApiToken("three", HASH_THREE, List.of("cluster:monitor"), List.of(), Instant.now(), Long.MAX_VALUE) + ); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(tokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + // Use a counter to verify listener is called exactly once + int[] callCount = { 0 }; + repository.reloadApiTokensFromIndex(ActionListener.wrap(unused -> callCount[0]++, e -> {})); + + assertEquals("Listener should be called exactly once regardless of token count", 1, callCount[0]); + } + + @Test + public void testReloadApiTokensFromIndexAndParse() throws IOException { + // Setup mock response + Map expectedTokens = Map.of( + "test", + new ApiToken("test", HASH_TEST, List.of("cluster:monitor"), List.of(), Instant.now(), 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(ActionListener.wrap(() -> { + // 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").getCluster_permissions() + ); + assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndex_permissions()); + }); + })); + } +} 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..03b7db83bf --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -0,0 +1,127 @@ +/* + * 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 org.junit.Before; +import org.junit.Test; + +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.opensearch.transport.client.Client; +import org.opensearch.transport.client.IndicesAdminClient; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +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 testApiTokenRoundTrip() throws IOException { + long expiration = 9999999999L; + Instant creationTime = Instant.ofEpochMilli(1700000000000L); + ApiToken original = new ApiToken( + "my-token", + ApiTokenRepository.hashToken("os_my_token"), + List.of("cluster:monitor", "cluster:admin"), + List.of(new ApiToken.IndexPermission(List.of("logs-*"), List.of("read", "write"))), + creationTime, + expiration + ); + + String json = original.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString(); + + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, json); + + ApiToken parsed = ApiToken.fromXContent(parser); + + assertEquals(original.getName(), parsed.getName()); + assertEquals(original.getClusterPermissions(), parsed.getClusterPermissions()); + assertEquals(original.getExpiration(), parsed.getExpiration()); + assertEquals(creationTime, parsed.getCreationTime()); + assertEquals(1, parsed.getIndexPermissions().size()); + assertThat(parsed.getIndexPermissions().get(0).getIndexPatterns(), equalTo(List.of("logs-*"))); + assertThat(parsed.getIndexPermissions().get(0).getAllowedActions(), equalTo(List.of("read", "write"))); + } + + @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/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index 1024bfc954..191f71b993 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 1d97e5a6bf..45e6140180 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -23,12 +23,12 @@ import org.apache.logging.log4j.core.Appender; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; -import org.junit.Assert; import org.junit.Test; import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.security.authtoken.jwt.claims.JwtClaimsBuilder; import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.support.ConfigConstants; @@ -43,6 +43,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -70,14 +71,14 @@ public void testCreateJwkFromSettings() { @Test public void testCreateJwkFromSettingsWithWeakKey() { Settings settings = Settings.builder().put("signing_key", "abcd1234").build(); - Throwable exception = Assert.assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings)); + Throwable exception = assertThrows(OpenSearchException.class, () -> JwtVendor.createJwkFromSettings(settings)); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } @Test public void testCreateJwkFromSettingsWithoutSigningKey() { Settings settings = Settings.builder().put("jwt", "").build(); - Throwable exception = Assert.assertThrows(RuntimeException.class, () -> JwtVendor.createJwkFromSettings(settings)); + Throwable exception = assertThrows(RuntimeException.class, () -> JwtVendor.createJwkFromSettings(settings)); assertThat( exception.getMessage(), equalTo("Settings for signing key is missing. Please specify at least the option signing_key with a shared secret.") @@ -259,4 +260,49 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String[] parts = logMessage.split("\\."); assertTrue(parts.length >= 3); } + + @Test + 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(); + + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor apiTokenJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = apiTokenJwtVendor.createJwt( + new JwtClaimsBuilder().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("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 testKeyTooShortForApiTokenThrowsException() { + String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); + 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 2de71d3441..15cb69ff58 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -190,7 +190,7 @@ public void issueOnBehalfOfToken_cannotFindUserInThreadContext() { @Test public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { - doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> 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")); @@ -211,7 +211,7 @@ public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { @Test public void issueOnBehalfOfToken_success() throws Exception { - doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> 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")); 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()); + } + } + } +}