diff --git a/CHANGELOG.md b/CHANGELOG.md index 57bb0d7db8..7e5327a8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Enhancements +* Create a mechanism for plugins to explicitly declare actions they need to perform with their assigned PluginSubject ([#5341](https://github.com/opensearch-project/security/pull/5341)) +* Moves OpenSAML jars to a Shadow Jar configuration to facilitate its use in FIPS enabled environments ([#5400](https://github.com/opensearch-project/security/pull/5404)) +* Replaced the standard distribution of BouncyCastle with BC-FIPS ([#5439](https://github.com/opensearch-project/security/pull/5439)) +* Introduce API Tokens with `cluster_permissions` and `index_permissions` directly associated with the token ([#5443](https://github.com/opensearch-project/security/pull/5443)) +* Introduced setting `plugins.security.privileges_evaluation.precomputed_privileges.enabled` ([#5465](https://github.com/opensearch-project/security/pull/5465)) +* Optimized wildcard matching runtime performance ([#5470](https://github.com/opensearch-project/security/pull/5470)) +* Optimized performance for construction of internal action privileges data structure ([#5470](https://github.com/opensearch-project/security/pull/5470)) + ### Bug Fixes * Added new option skip_users to client cert authenticator (clientcert_auth_domain.http_authenticator.config.skip_users in config.yml)([#4378](https://github.com/opensearch-project/security/pull/5525)) 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..a929e734e7 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java @@ -0,0 +1,263 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +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.junit.runner.RunWith; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.XContentBuilder; +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.equalTo; +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; + +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +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"; + private static final String signingKey = Base64.getEncoder() + .encodeToString( + "jwt signing key for api token authentication backend for testing of API Token authentication".getBytes(StandardCharsets.UTF_8) + ); + private static final String alternativeSigningKey = Base64.getEncoder() + .encodeToString( + "alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".getBytes(StandardCharsets.UTF_8) + ); + + 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_SUBJECT = "token:test-token"; + public static final String TEST_TOKEN_PAYLOAD = """ + { + "name": "test-token", + "cluster_permissions": ["cluster_monitor"] + } + """; + + 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).signingKey(signingKey); + } + + @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()); + } + + @Test + public void testAuthInfoEndpoint() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken); + authenticateWithApiToken(authHeader, HttpStatus.SC_OK); + } + + @Test + public void testCallingClusterHealthWithApiToken_success() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "Bearer " + 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", "Bearer " + apiToken); + authenticateWithApiToken(authHeader, HttpStatus.SC_UNAUTHORIZED); + } + + @Test + public void shouldNotBeAbleToUseTokenToGenerateMoreTokens() { + String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD); + Header authHeader = new BasicHeader("Authorization", "Bearer " + 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", "Bearer " + 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"), equalTo("Invalid request: expiration must be a long")); + } + } + + @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"), equalTo("Invalid request: Unknown field in request: foo")); + } + } + + @Test + public void shouldNotAllowTokenWhenApiTokensAreDisabled() { + final Header apiTokenHeader = new BasicHeader("Authorization", "Bearer " + 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 apiTokenSigningCheckChangeIsDetected() { + final Header apiTokenOriginalKey = new BasicHeader("Authorization", "Bearer " + generateApiToken(TEST_TOKEN_PAYLOAD)); + authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_OK); + + // Change the signing key + patchApiTokenConfig(defaultApiTokenConfig().signingKey(alternativeSigningKey)); + + // Original key should no longer work + authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_UNAUTHORIZED); + + // Generate new key, check that it is valid + final Header apiTokenOtherKey = new BasicHeader("Authorization", "Bearer " + generateApiToken(TEST_TOKEN_PAYLOAD)); + authenticateWithApiToken(apiTokenOtherKey, HttpStatus.SC_OK); + + // Change back to the original signing key and the original key still works, and the new key doesn't + patchApiTokenConfig(defaultApiTokenConfig()); + authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_OK); + authenticateWithApiToken(apiTokenOtherKey, HttpStatus.SC_UNAUTHORIZED); + } + + 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, equalTo(ApiTokenTest.TEST_TOKEN_SUBJECT)); + } + } + } + + 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..fe3e3d2df7 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/ApiTokenConfig.java @@ -0,0 +1,39 @@ +/* + * 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; + private String signing_key; + + public ApiTokenConfig enabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public ApiTokenConfig signingKey(String signing_key) { + this.signing_key = signing_key; + return this; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field("enabled", enabled); + xContentBuilder.field("signing_key", signing_key); + 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 d58070ab45..b86e76e82d 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -149,6 +149,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; @@ -265,6 +270,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; @@ -290,6 +296,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; @@ -314,6 +325,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 c1e6fca059..f7cbbda6d9 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java @@ -53,6 +53,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; @@ -566,6 +567,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 cd58773fea..ce0fc3fa28 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -134,6 +134,10 @@ import org.opensearch.search.internal.ReaderContext; import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; +import org.opensearch.security.action.apitokens.ApiTokenAction; +import org.opensearch.security.action.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; @@ -275,6 +279,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<>(); @@ -670,6 +675,21 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); + handlers.add( + new ApiTokenAction( + Objects.requireNonNull(threadPool), + cr, + evaluator, + settings, + adminDns, + auditLog, + configPath, + principalExtractor, + apiTokenRepository, + cs, + indexNameExpressionResolver + ) + ); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -721,6 +741,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)); @@ -1165,6 +1186,7 @@ public Collection createComponents( backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool, cih); backendRegistry.registerClusterSettingsChangeListener(clusterService.getClusterSettings()); tokenManager = new SecurityTokenManager(cs, threadPool, userService); + apiTokenRepository = new ApiTokenRepository(localClient, clusterService, tokenManager); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1180,7 +1202,8 @@ public Collection createComponents( settings, privilegesInterceptor, cih, - irr + irr, + apiTokenRepository ); dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); @@ -1253,7 +1276,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); @@ -1304,6 +1327,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(apiTokenRepository); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -2347,6 +2371,11 @@ public Collection getSystemIndexDescriptors(Settings sett ); final SystemIndexDescriptor securityIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); systemIndexDescriptors.add(securityIndexDescriptor); + final SystemIndexDescriptor apiTokenSystemIndexDescriptor = new SystemIndexDescriptor( + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, + "Security API token index" + ); + systemIndexDescriptors.add(apiTokenSystemIndexDescriptor); if (settings != null && settings.getAsBoolean( ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, 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..0fa0407335 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -0,0 +1,237 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +public class ApiToken implements ToXContent { + public static final String NAME_FIELD = "name"; + public static final String 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 Set ALLOWED_FIELDS = Set.of( + NAME_FIELD, + EXPIRATION_FIELD, + CLUSTER_PERMISSIONS_FIELD, + INDEX_PERMISSIONS_FIELD + ); + + private final String name; + private final Instant creationTime; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; + + public ApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Instant creationTime, + Long expiration + ) { + this.name = name; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.creationTime = creationTime; + this.expiration = expiration; + } + + 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; + } + + public static IndexPermission fromXContent(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + + return new IndexPermission(indexPatterns, allowedActions); + } + + } + + /** + * Class represents an API token. + * Expected class structure + * { + * name: "token_name", + * jti: "encrypted_token", + * creation_time: 1234567890, + * cluster_permissions: ["cluster_permission1", "cluster_permission2"], + * index_permissions: [ + * { + * index_pattern: ["index_pattern1", "index_pattern2"], + * allowed_actions: ["allowed_action1", "allowed_action2"] + * } + * ], + * expiration: 1234567890 + * } + */ + public static ApiToken fromXContent(XContentParser parser) throws IOException { + String name = null; + List clusterPermissions = new ArrayList<>(); + List indexPermissions = new ArrayList<>(); + Instant creationTime = null; + long expiration = 0; + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + switch (currentFieldName) { + case NAME_FIELD: + name = parser.text(); + break; + case ISSUED_AT_FIELD: + creationTime = Instant.ofEpochMilli(parser.longValue()); + break; + case EXPIRATION_FIELD: + expiration = parser.longValue(); + break; + } + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case CLUSTER_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + clusterPermissions.add(parser.text()); + } + break; + case INDEX_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + indexPermissions.add(parseIndexPermission(parser)); + } + } + break; + } + } + } + + return new ApiToken(name, clusterPermissions, indexPermissions, creationTime, expiration); + } + + private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + return new IndexPermission(indexPatterns, allowedActions); + } + + public String getName() { + return name; + } + + public 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(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); + xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); + xContentBuilder.field(ISSUED_AT_FIELD, creationTime.toEpochMilli()); + xContentBuilder.endObject(); + return xContentBuilder; + } + + public List getIndexPermissions() { + return indexPermissions; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java new file mode 100644 index 0000000000..590c100ed6 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -0,0 +1,398 @@ +/* + * 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.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; +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.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.PrivilegesEvaluator; +import org.opensearch.security.ssl.transport.PrincipalExtractor; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_FIELDS; +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_PATTERN_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.ISSUED_AT_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.opensearch.security.dlic.rest.api.Responses.forbidden; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; + +public class ApiTokenAction extends BaseRestHandler { + private final ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); + private final ThreadPool threadPool; + private final ConfigurationRepository configurationRepository; + private final PrivilegesEvaluator privilegesEvaluator; + private final SecurityApiDependencies securityApiDependencies; + private final ClusterService clusterService; + private final IndexNameExpressionResolver indexNameExpressionResolver; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) + ); + + public ApiTokenAction( + ThreadPool threadpool, + ConfigurationRepository configurationRepository, + PrivilegesEvaluator privilegesEvaluator, + Settings settings, + AdminDNs adminDns, + AuditLog auditLog, + Path configPath, + PrincipalExtractor principalExtractor, + ApiTokenRepository apiTokenRepository, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver + ) { + this.apiTokenRepository = apiTokenRepository; + this.threadPool = threadpool; + this.configurationRepository = configurationRepository; + this.privilegesEvaluator = privilegesEvaluator; + this.securityApiDependencies = new SecurityApiDependencies( + adminDns, + configurationRepository, + privilegesEvaluator, + new RestApiPrivilegesEvaluator(settings, adminDns, privilegesEvaluator, principalExtractor, configPath, threadPool), + new RestApiAdminPrivilegesEvaluator( + threadPool.getThreadContext(), + privilegesEvaluator, + adminDns, + settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) + ), + auditLog, + settings + ); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; + } + + @Override + 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 { + 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(NAME_FIELD, token.getName()); + builder.field(ISSUED_AT_FIELD, token.getCreationTime().toEpochMilli()); + builder.field(EXPIRATION_FIELD, token.getExpiration()); + builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); + builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); + builder.endObject(); + } + builder.endArray(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + builder.close(); + channel.sendResponse(response); + } catch (final Exception exception) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + } + }, exception -> { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + + })); + + }; + } + + private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { + return channel -> { + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + validateRequestParameters(requestBody); + + List clusterPermissions = extractClusterPermissions(requestBody); + List indexPermissions = extractIndexPermissions(requestBody); + String name = (String) requestBody.get(NAME_FIELD); + long expiration = (Long) requestBody.getOrDefault( + EXPIRATION_FIELD, + Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30) + ); + + // First check token count + apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> { + if (tokenCount >= 100) { + sendErrorResponse( + channel, + RestStatus.TOO_MANY_REQUESTS, + "Maximum limit of 100 API tokens reached. Please delete existing tokens before creating new ones." + ); + return; + } + + apiTokenRepository.createApiToken( + name, + clusterPermissions, + indexPermissions, + expiration, + wrapWithCacheRefresh(ActionListener.wrap(token -> { + apiTokenRepository.notifyAboutChanges(); + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("token", token); + builder.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 ActionListener wrapWithCacheRefresh(ActionListener listener, NodeClient client) { + return ActionListener.wrap(response -> { + try { + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute( + ApiTokenUpdateAction.INSTANCE, + updateRequest, + ActionListener.wrap( + updateResponse -> listener.onResponse(response), + exception -> listener.onFailure(new ApiTokenException("Failed to refresh cache", exception)) + ) + ); + } catch (Exception e) { + listener.onFailure(new ApiTokenException("Failed to refresh cache after operation", e)); + } + }, listener::onFailure); + } + + /** + * Extracts cluster permissions from the request body + */ + List extractClusterPermissions(Map requestBody) { + return safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); + } + + /** + * Extracts and builds index permissions from the request body + */ + List extractIndexPermissions(Map requestBody) { + List> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList()); + } + + /** + * Creates a single index permission from a permission map + */ + ApiToken.IndexPermission createIndexPermission(Map indexPerm) { + List indexPatterns; + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (indexPatternObj instanceof String) { + indexPatterns = Collections.singletonList((String) indexPatternObj); + } else { + indexPatterns = safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); + } + + List allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); + + return new ApiToken.IndexPermission(indexPatterns, allowedActions); + } + + /** + * Validates the request parameters + */ + void validateRequestParameters(Map requestBody) { + // Check for unknown fields + for (String field : requestBody.keySet()) { + if (!ALLOWED_FIELDS.contains(field)) { + throw new IllegalArgumentException("Unknown field in request: " + field); + } + } + if (!requestBody.containsKey(NAME_FIELD)) { + throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD); + } + + if (requestBody.containsKey(EXPIRATION_FIELD)) { + Object expiration = requestBody.get(EXPIRATION_FIELD); + if (!(expiration instanceof Long)) { + throw new IllegalArgumentException(EXPIRATION_FIELD + " must be a long"); + } + } + + if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) { + Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD); + if (!(permissions instanceof List)) { + throw new IllegalArgumentException(CLUSTER_PERMISSIONS_FIELD + " must be an array"); + } + } + + if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) { + List> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + validateIndexPermissionsList(indexPermsList); + } + } + + /** + * Validates the index permissions list structure + */ + void validateIndexPermissionsList(List> indexPermsList) { + for (Map indexPerm : indexPermsList) { + if (!indexPerm.containsKey(INDEX_PATTERN_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + INDEX_PATTERN_FIELD); + } + if (!indexPerm.containsKey(ALLOWED_ACTIONS_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + ALLOWED_ACTIONS_FIELD); + } + + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (!(indexPatternObj instanceof String) && !(indexPatternObj instanceof List)) { + throw new IllegalArgumentException(INDEX_PATTERN_FIELD + " must be a string or array of strings"); + } + } + } + + private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { + return channel -> { + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + + validateRequestParameters(requestBody); + apiTokenRepository.deleteApiToken( + (String) requestBody.get(NAME_FIELD), + wrapWithCacheRefresh(ActionListener.wrap(ignored -> { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "Token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + }, + deleteException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to delete token: " + deleteException.getMessage() + ) + ), client) + ); + } catch (final Exception exception) { + RestStatus status = RestStatus.INTERNAL_SERVER_ERROR; + if (exception instanceof ApiTokenException) { + status = RestStatus.NOT_FOUND; + } + sendErrorResponse(channel, status, exception.getMessage()); + } + }; + } + + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + BytesRestResponse response = new BytesRestResponse(status, builder); + channel.sendResponse(response); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + + protected String authorizeSecurityAccess(RestRequest request) throws IOException { + // Check if user has security API access + if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) + || securityApiDependencies.restApiPrivilegesEvaluator().checkAccessPermissions(request, Endpoint.APITOKENS) == null)) { + 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..2134ff0383 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.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.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.index.query.QueryBuilders; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.transport.client.Client; + +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; + +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(null); + }, 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 deleteToken(String name, ActionListener listener) { + DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( + QueryBuilders.matchQuery(NAME_FIELD, name) + ).setRefresh(true); + + client.execute(DeleteByQueryAction.INSTANCE, request, ActionListener.wrap(response -> { + long deletedDocs = response.getDeleted(); + if (deletedDocs == 0) { + listener.onFailure(new ApiTokenException("No token found with name " + name)); + } else { + listener.onResponse(null); + } + }, exception -> listener.onFailure(exception))); + } + + public void getTokenMetadatas(ActionListener> listener) { + try { + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder()); + + client.search(searchRequest, ActionListener.wrap(response -> { + try { + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + ApiToken token = ApiToken.fromXContent(parser); + tokens.put("token:" + token.getName(), 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..38542a7492 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -0,0 +1,160 @@ +/* + * 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.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.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.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.configuration.TokenListener; +import org.opensearch.security.identity.SecurityTokenManager; +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 { + private final ApiTokenIndexHandler apiTokenIndexHandler; + private final SecurityTokenManager securityTokenManager; + private final List tokenListener = new ArrayList<>(); + private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); + + private final Map jtis = new ConcurrentHashMap<>(); + + void reloadApiTokensFromIndex(ActionListener listener) { + apiTokenIndexHandler.getTokenMetadatas(new ActionListener>() { + @Override + public void onResponse(Map tokenMetadatas) { + jtis.keySet().removeIf(key -> !tokenMetadatas.containsKey(key)); + tokenMetadatas.forEach((key, 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); + jtis.put(key, role); + listener.onResponse(null); + }); + } + + @Override + public void onFailure(Exception e) { + listener.onFailure(new OpenSearchSecurityException("Received error while reloading API tokens metadata from index", e)); + } + }); + } + + 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 jti = user.getName().split(API_TOKEN_USER_PREFIX)[1]; + if (isValidToken(jti)) { + return getPermissionsForJti(jti); + } + } + return new RoleV7(); + } + + public RoleV7 getPermissionsForJti(String jti) { + return jtis.get(jti); + } + + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); + } + + public Map getJtis() { + return jtis; + } + + public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { + apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + securityTokenManager = tokenManager; + } + + private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + this.apiTokenIndexHandler = apiTokenIndexHandler; + this.securityTokenManager = securityTokenManager; + } + + @VisibleForTesting + static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager); + } + + public void createApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Long expiration, + ActionListener listener + ) { + apiTokenIndexHandler.createApiTokenIndexIfAbsent(ActionListener.wrap(() -> { + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); + ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, Instant.now(), expiration); + apiTokenIndexHandler.indexTokenMetadata( + apiToken, + ActionListener.wrap(unused -> { listener.onResponse(token.getCompleteToken()); }, listener::onFailure) + ); + })); + + } + + public void deleteApiToken(String name, ActionListener listener) throws ApiTokenException, IndexNotFoundException { + apiTokenIndexHandler.deleteToken(name, listener); + } + + public void getApiTokens(ActionListener> listener) { + apiTokenIndexHandler.createApiTokenIndexIfAbsent(ActionListener.wrap(() -> { apiTokenIndexHandler.getTokenMetadatas(listener); })); + + } + + 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..99d94bd578 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenUpdateResponse extends BaseNodesResponse implements ToXContentObject { + + public ApiTokenUpdateResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateResponse( + final ClusterName clusterName, + List nodes, + List failures + ) { + super(clusterName, nodes, failures); + } + + @Override + public List readNodesFrom(final StreamInput in) throws IOException { + return in.readList(ApiTokenUpdateNodeResponse::new); + } + + @Override + public void writeNodesTo(final StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("ApiTokenupdate_response"); + builder.field("nodes", getNodesMap()); + builder.field("node_size", getNodes().size()); + builder.field("has_failures", hasFailures()); + builder.field("failures_size", failures().size()); + builder.endObject(); + + return builder; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java new file mode 100644 index 0000000000..fb62a7cf83 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,125 @@ +/* + * 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 java.util.concurrent.CompletableFuture; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenRepository apiTokenRepository; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + ApiTokenRepository apiTokenRepository + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenRepository = apiTokenRepository; + this.clusterService = clusterService; + } + + public static class NodeApiTokenUpdateRequest extends TransportRequest { + ApiTokenUpdateRequest request; + + public NodeApiTokenUpdateRequest(ApiTokenUpdateRequest request) { + this.request = request; + } + + public NodeApiTokenUpdateRequest(StreamInput streamInput) throws IOException { + super(streamInput); + this.request = new ApiTokenUpdateRequest(streamInput); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + } + + @Override + protected ApiTokenUpdateNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ApiTokenUpdateNodeResponse(in); + } + + @Override + protected ApiTokenUpdateResponse newResponse( + ApiTokenUpdateRequest request, + List responses, + List failures + ) { + return new ApiTokenUpdateResponse(this.clusterService.getClusterName(), responses, failures); + } + + @Override + protected NodeApiTokenUpdateRequest newNodeRequest(ApiTokenUpdateRequest request) { + return new NodeApiTokenUpdateRequest(request); + } + + @Override + protected ApiTokenUpdateNodeResponse nodeOperation(final NodeApiTokenUpdateRequest request) { + CompletableFuture future = new CompletableFuture<>(); + + ActionListener reloadListener = new ActionListener<>() { + @Override + public void onResponse(Void unused) { + future.complete(new ApiTokenUpdateNodeResponse(clusterService.localNode())); + } + + @Override + public void onFailure(Exception e) { + future.completeExceptionally(e); + } + }; + + try { + apiTokenRepository.reloadApiTokensFromIndex(reloadListener); + return future.get(); // This will block until the future completes + } catch (Exception e) { + throw new RuntimeException("Failed to reload API tokens", e); + } + } +} 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 34d0a0ba29..e3a49e8411 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -73,6 +73,7 @@ import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.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/authtoken/jwt/claims/ApiJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java new file mode 100644 index 0000000000..ebb5552045 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +public class ApiJwtClaimsBuilder extends JwtClaimsBuilder { + + public ApiJwtClaimsBuilder() { + super(); + } +} diff --git a/src/main/java/org/opensearch/security/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 383ad1368d..ec9bf4a17b 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -30,5 +30,6 @@ public enum Endpoint { ALLOWLIST, NODESDN, SSL, - RESOURCE_SHARING + RESOURCE_SHARING, + APITOKENS; } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index faa0217db2..768f9d2f70 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -70,6 +70,7 @@ default String build() { .put(Endpoint.ROLES, action -> buildEndpointPermission(Endpoint.ROLES)) .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) + .put(Endpoint.APITOKENS, action -> buildEndpointPermission(Endpoint.APITOKENS)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) .build(); diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java new file mode 100644 index 0000000000..1a718387fd --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,224 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.http; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + + public Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final Boolean apiTokenEnabled; + private final String clusterName; + public static final String API_TOKEN_USER_PREFIX = "token:"; + private final ApiTokenRepository apiTokenRepository; + + @SuppressWarnings("removal") + public ApiTokenAuthenticator(Settings settings, String clusterName, ApiTokenRepository apiTokenRepository) { + String apiTokenEnabledSetting = settings.get("enabled", "true"); + apiTokenEnabled = Boolean.parseBoolean(apiTokenEnabledSetting); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); + } + }); + this.clusterName = clusterName; + this.apiTokenRepository = apiTokenRepository; + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find api token authenticator signing_key"); + } + + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); + } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + return jwtParserBuilder; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) + throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request, context); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + if (claims.getSubject() == null) { + log.error("Api token does not have a subject"); + return null; + } + + final String subject = API_TOKEN_USER_PREFIX + claims.getSubject(); + + if (!apiTokenRepository.isValidToken(subject)) { + log.error("Api token is not allowlisted"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this api token does not match the current cluster identifier"); + return null; + } + + return new AuthCredentials(subject, List.of(), "").markComplete(); + + } catch (WeakKeyException e) { + log.error("Cannot authenticate api token because of ", e); + return null; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired api token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(SecurityRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "apitoken_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 519162c980..f726a8134b 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,6 +11,8 @@ package org.opensearch.security.identity; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Date; import java.util.Set; @@ -30,6 +32,7 @@ import org.opensearch.identity.tokens.TokenManager; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; @@ -55,6 +58,7 @@ public class SecurityTokenManager implements TokenManager { private final UserService userService; private Settings oboSettings = null; + private Settings apiTokenSettings = null; private ConfigModel configModel = null; private final LongSupplier timeProvider = System::currentTimeMillis; private static final Integer OBO_MAX_EXPIRY_SECONDS = 600; @@ -77,6 +81,11 @@ public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { if (oboEnabled) { oboSettings = oboSettingsFromDcm; } + final Settings apiTokenSettingsFromDcm = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettingsFromDcm.getAsBoolean("enabled", false); + if (apiTokenEnabled) { + apiTokenSettings = apiTokenSettingsFromDcm; + } } /** For testing */ @@ -93,6 +102,10 @@ public boolean issueOnBehalfOfTokenAllowed() { return oboSettings != null && configModel != null; } + public boolean issueApiTokenAllowed() { + return apiTokenSettings != null && configModel != null; + } + @Override public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final OnBehalfOfClaims claims) { if (!issueOnBehalfOfTokenAllowed()) { @@ -155,6 +168,38 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } } + public ExpiringBearerAuthToken issueApiToken(final String name, final Long expiration) { + if (!issueApiTokenAllowed()) { + throw new OpenSearchSecurityException("Api token generation is not enabled."); + } + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final ApiJwtClaimsBuilder claimsBuilder = new ApiJwtClaimsBuilder(); + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(name); + claimsBuilder.audience(name); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + + try { + return createJwtVendor(apiTokenSettings).createJwt( + claimsBuilder, + name, + expiryTime, + Duration.between(Instant.now(), expiryTime.toInstant()).getSeconds() + ); + } catch (final Exception ex) { + logger.error("Error creating Api Token for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate Api Token"); + } + } + @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 65c98d7165..f3ced0d950 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -87,6 +87,7 @@ import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.index.reindex.ReindexAction; import org.opensearch.script.mustache.RenderSearchTemplateAction; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.configuration.ConfigurationRepository; @@ -169,6 +170,7 @@ public class PrivilegesEvaluator { private final AtomicReference actionPrivileges = new AtomicReference<>(); private final AtomicReference tenantPrivileges = new AtomicReference<>(); private final Map pluginIdToActionPrivileges = new HashMap<>(); + private ApiTokenRepository apiTokenRepository; /** * The pure static action groups should be ONLY used by action privileges for plugins; only those cannot and should @@ -189,7 +191,8 @@ public PrivilegesEvaluator( final Settings settings, final PrivilegesInterceptor privilegesInterceptor, final ClusterInfoHolder clusterInfoHolder, - final IndexResolverReplacer irr + final IndexResolverReplacer irr, + final ApiTokenRepository apiTokenRepository ) { super(); @@ -230,6 +233,22 @@ public PrivilegesEvaluator( }); } + if (apiTokenRepository != null) { + // TODO Also ensure these are read on node bootstrap + apiTokenRepository.subscribeOnChange(() -> { + SecurityDynamicConfiguration actionGroupsConfiguration = configurationRepository.getConfiguration( + CType.ACTIONGROUPS + ); + FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguration.withStaticConfig()); + for (Map.Entry entry : apiTokenRepository.getJtis().entrySet()) { + pluginIdToActionPrivileges.put( + entry.getKey(), + new SubjectBasedActionPrivileges(entry.getValue(), flattenedActionGroups) + ); + } + }); + } + if (clusterService != null) { clusterService.addListener(event -> { RoleBasedActionPrivileges actionPrivileges = PrivilegesEvaluator.this.actionPrivileges.get(); @@ -238,6 +257,8 @@ public PrivilegesEvaluator( } }); } + + this.apiTokenRepository = apiTokenRepository; } void updateConfiguration( @@ -350,7 +371,7 @@ public PrivilegesEvaluationContext createContext( ActionPrivileges actionPrivileges; ImmutableSet mappedRoles; - if (user.isPluginUser()) { + if (user.isPluginUser() || user.isApiTokenRequest()) { mappedRoles = ImmutableSet.of(); actionPrivileges = this.pluginIdToActionPrivileges.get(user.getName()); if (actionPrivileges == null) { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 249c1a8a15..4ef5beac1b 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 { @@ -276,7 +280,7 @@ public void onChange(ConfigurationMap typeToConfig) { ); // rebuild v7 Models - dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); + dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih, apiTokenRepository); ium = new InternalUsersModelV7(internalusers, roles, rolesmapping); cm = new ConfigModelV7(roles, rolesmapping, dcm, opensearchSettings); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java index ea8769dcf5..c97020052c 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java @@ -118,6 +118,8 @@ public abstract class DynamicConfigModel { public abstract Settings getDynamicOnBehalfOfSettings(); + public abstract Settings getDynamicApiTokenSettings(); + protected final Map authImplMap = new HashMap<>(); public DynamicConfigModel() { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 4bc9e82882..55271e960b 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -49,6 +49,7 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auth.AuthDomain; import org.opensearch.security.auth.AuthFailureListener; import org.opensearch.security.auth.AuthenticationBackend; @@ -59,6 +60,7 @@ import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.v7.ConfigV7; @@ -85,13 +87,15 @@ public class DynamicConfigModelV7 extends DynamicConfigModel { private List> ipClientBlockRegistries; private Multimap> authBackendClientBlockRegistries; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; public DynamicConfigModelV7( ConfigV7 config, Settings opensearchSettings, Path configPath, InternalAuthenticationBackend iab, - ClusterInfoHolder cih + ClusterInfoHolder cih, + ApiTokenRepository apiTokenRepository ) { super(); this.config = config; @@ -99,6 +103,7 @@ public DynamicConfigModelV7( this.configPath = configPath; this.iab = iab; this.cih = cih; + this.apiTokenRepository = apiTokenRepository; buildAAA(); } @@ -234,6 +239,13 @@ public Settings getDynamicOnBehalfOfSettings() { .build(); } + @Override + public Settings getDynamicApiTokenSettings() { + return Settings.builder() + .put(Settings.builder().loadFromSource(config.dynamic.api_tokens.configAsJson(), XContentType.JSON).build()) + .build(); + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); @@ -370,6 +382,23 @@ private void buildAAA() { } } + /* + * If the Api token authentication is configured: + * Add the ApiToken authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when ApiToken authentication failed + * order: -2 - prioritize the Api token authentication when it gets enabled + */ + Settings apiTokenSettings = getDynamicApiTokenSettings(); + if (!isKeyNull(apiTokenSettings, "signing_key")) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName(), apiTokenRepository), + false, + -2 + ); + restAuthDomains0.add(_ad); + } + /* * If the OnBehalfOf (OBO) authentication is configured: * Add the OBO authbackend in to the auth domains diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index def5247590..0c081c2eba 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("signing_key") + private String signingKey; + + @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 String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + @Override + public String toString() { + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + "]"; + } + + } + } diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 4683075f1d..32a70a468f 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -68,6 +68,10 @@ public static OpenSearchException invalidUsageOfOBOTokenException() { return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); } + public static OpenSearchException invalidUsageOfApiTokenException() { + return new OpenSearchException("Api Tokens are not allowed to be used for accessing this endpoint."); + } + public static OpenSearchException createJwkCreationException() { return new OpenSearchException("An error occurred during the creation of Jwk."); } diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 1f9ac97090..8a36d9cf8c 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -421,6 +421,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 3884bf75fe..caccb91407 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -20,6 +20,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String API_TOKEN_SUFFIX = "api/apitokens"; public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { if (suffix == null) { @@ -28,6 +29,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques switch (suffix) { case ON_BEHALF_OF_SUFFIX: return request.method() == POST; + case API_TOKEN_SUFFIX: + // Don't want to allow any api token access + return true; case ACCOUNT_SUFFIX: return request.method() == PUT; default: diff --git a/src/main/java/org/opensearch/security/util/ParsingUtils.java b/src/main/java/org/opensearch/security/util/ParsingUtils.java new file mode 100644 index 0000000000..1a33ec46b4 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/ParsingUtils.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ParsingUtils { + + /** + * Safely casts an Object to List with validation + */ + public static List safeStringList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof String)) { + throw new IllegalArgumentException(fieldName + " must contain only strings"); + } + } + + return list.stream().map(String.class::cast).collect(Collectors.toList()); + } + + /** + * Safely casts an Object to List> with validation + */ + @SuppressWarnings("unchecked") + public static List> safeMapList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof Map)) { + throw new IllegalArgumentException(fieldName + " must contain object entries"); + } + } + return list.stream().map(item -> (Map) item).collect(Collectors.toList()); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java new file mode 100644 index 0000000000..7c52f07ae7 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -0,0 +1,235 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.threadpool.ThreadPool; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration.fromMap; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenActionTest { + @Mock + private ThreadPool threadPool; + + @Mock + private PrivilegesEvaluator privilegesEvaluator; + + @Mock + private ConfigurationRepository configurationRepository; + + @Mock + private ClusterService clusterService; + @Mock + private ClusterState clusterState; + + @Mock + private Metadata metadata; + + private SecurityDynamicConfiguration actionGroupsConfig; + private SecurityDynamicConfiguration rolesConfig; + private FlattenedActionGroups flattenedActionGroups; + private ApiTokenAction apiTokenAction; + + @Before + public void setUp() throws JsonProcessingException { + // Setup basic action groups + + actionGroupsConfig = SecurityDynamicConfiguration.fromMap( + ImmutableMap.of( + "read_group", + Map.of("allowed_actions", List.of("read", "get", "search")), + "write_group", + Map.of("allowed_actions", List.of("write", "create", "index")) + ), + CType.ACTIONGROUPS + ); + + rolesConfig = fromMap( + ImmutableMap.of( + "read_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "read_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "more_permissable_write_group_lo-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "cluster_monitor", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ), + "alias_group", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs"), "allowed_actions", List.of("read"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ) + + ), + CType.ROLES + ); + + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + + apiTokenAction = new ApiTokenAction( + + threadPool, + configurationRepository, + privilegesEvaluator, + Settings.EMPTY, + null, + null, + null, + null, + null, + clusterService, + null + ); + + } + + @Test + public void testCreateIndexPermission() { + Map validPermission = new HashMap<>(); + validPermission.put("index_pattern", "test-*"); + validPermission.put("allowed_actions", List.of("read")); + + ApiToken.IndexPermission result = apiTokenAction.createIndexPermission(validPermission); + + assertThat(result.getIndexPatterns(), is(List.of("test-*"))); + assertThat(result.getAllowedActions(), is(List.of("read"))); + } + + @Test + public void testValidateRequestParameters() { + Map validRequest = new HashMap<>(); + validRequest.put("name", "test-token"); + validRequest.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + apiTokenAction.validateRequestParameters(validRequest); + + // Missing name + Map missingName = new HashMap<>(); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(missingName)); + + // Invalid cluster_permissions type + Map invalidClusterPerms = new HashMap<>(); + invalidClusterPerms.put("name", "test"); + invalidClusterPerms.put("cluster_permissions", "not a list"); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(invalidClusterPerms)); + } + + @Test + public void testValidateIndexPermissionsList() { + Map validPerm = new HashMap<>(); + validPerm.put("index_pattern", "test-*"); + validPerm.put("allowed_actions", List.of("read")); + apiTokenAction.validateIndexPermissionsList(Collections.singletonList(validPerm)); + + // Missing index_pattern + Map missingPattern = new HashMap<>(); + missingPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingPattern)) + ); + + // Missing allowed_actions + Map missingActions = new HashMap<>(); + missingActions.put("index_pattern", "test-*"); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingActions)) + ); + + // Invalid index_pattern type + Map invalidPattern = new HashMap<>(); + invalidPattern.put("index_pattern", 123); + invalidPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(invalidPattern)) + ); + } + + @Test + public void testExtractClusterPermissions() { + Map requestBody = new HashMap<>(); + + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(empty())); + + requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..a85cfa330f --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; + +import org.apache.logging.log4j.Logger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.security.user.AuthCredentials; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenAuthenticatorTest { + + private ApiTokenAuthenticator authenticator; + @Mock + private Logger log; + + @Mock + private ApiTokenRepository apiTokenRepository; + + private ThreadContext threadcontext; + private final String signingKey = Base64.getEncoder() + .encodeToString("jwt signing key long enough for secure api token authentication testing".getBytes(StandardCharsets.UTF_8)); + private final String tokenName = "test-token"; + + @Before + public void setUp() { + Settings settings = Settings.builder().put("enabled", "true").put("signing_key", signingKey).build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + when(log.isDebugEnabled()).thenReturn(true); + threadcontext = new ThreadContext(Settings.EMPTY); + } + + @Test + public void testAuthenticationFailsWhenJtiNotInCache() { + String testJti = "test-jti-not-in-cache"; + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + AuthCredentials credentials = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is not in allowlist cache", credentials); + } + + @Test + public void testExtractCredentialsPassWhenJtiInCache() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken("token:" + tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNotNull("Should not be null when JTI is in allowlist cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().minus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is expired", ac); + verify(log).debug(eq("Invalid or expired api token."), any(ExpiredJwtException.class)); + + } + + @Test + public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { + String token = Jwts.builder() + .setIssuer("not-opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken("token:" + tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when issuer does not match cluster", ac); + verify(log).error(eq("The issuer of this api token does not match the current cluster identifier")); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is being used to access restricted endpoint", ac); + verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); + } + + @Test + public void testAuthenticatorNotEnabled() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + + Settings settings = Settings.builder() + .put("enabled", "false") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when api tokens auth is not enabled", ac); + verify(log).error(eq("Api token authentication is disabled")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java new file mode 100644 index 0000000000..3f666ebca0 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -0,0 +1,302 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.apache.lucene.search.TotalHits; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.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.index.query.MatchQueryBuilder; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.security.support.ConfigConstants; +import org.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.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@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 testDeleteApiTokeCallsDeleteByQueryWithSuppliedName() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + String tokenName = "token"; + + TestActionListener listener = new TestActionListener<>(); + + doAnswer(invocation -> { + DeleteByQueryRequest request = invocation.getArgument(1); + ActionListener parentListener = invocation.getArgument(2); + + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + + parentListener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + indexHandler.deleteToken(tokenName, listener); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteByQueryRequest.class); + verify(client).execute(eq(DeleteByQueryAction.INSTANCE), captor.capture(), any(ActionListener.class)); + + listener.assertSuccess(); + + DeleteByQueryRequest capturedRequest = captor.getValue(); + MatchQueryBuilder query = (MatchQueryBuilder) capturedRequest.getSearchRequest().source().query(); + assertThat(query.fieldName(), equalTo(NAME_FIELD)); + assertThat(query.value(), equalTo(tokenName)); + } + + @Test + public void testDeleteTokenThrowsExceptionWhenNoDocumentsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(0L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + String tokenName = "nonexistent-token"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("No token found with name " + tokenName)); + } + + @Test + public void testDeleteTokenSucceedsWhenDocumentIsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + String tokenName = "existing-token"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, 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", + clusterPermissions, + indexPermissions, + Instant.now(), + Long.MAX_VALUE + ); + + // Mock the index response + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(mock(IndexResponse.class)); + return null; + }).when(client).index(any(IndexRequest.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + indexHandler.indexTokenMetadata(token, listener); + + listener.assertSuccess(); + + 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", + 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", + 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("token:token1-description"), is(true)); + assertThat(resultTokens.containsKey("token:token2-description"), is(true)); + + ApiToken resultToken1 = resultTokens.get("token:token1-description"); + assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); + + ApiToken resultToken2 = resultTokens.get("token:token2-description"); + assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java new file mode 100644 index 0000000000..c1c68392bc --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -0,0 +1,277 @@ +/* + * 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.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; +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; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenRepositoryTest { + @Mock + private SecurityTokenManager securityTokenManager; + @Mock + private ApiTokenIndexHandler apiTokenIndexHandler; + private ApiTokenRepository repository; + + @Before + public void setUp() { + apiTokenIndexHandler = mock(ApiTokenIndexHandler.class); + securityTokenManager = mock(SecurityTokenManager.class); + repository = ApiTokenRepository.forTest(apiTokenIndexHandler, securityTokenManager); + } + + @Test + public void testDeleteApiToken() throws ApiTokenException { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + listener.assertSuccess(); + verify(apiTokenIndexHandler).deleteToken(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: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("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("token1", new ApiToken("token1", Arrays.asList("perm1"), Arrays.asList(), Instant.now(), Long.MAX_VALUE)); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(0); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).createApiTokenIndexIfAbsent(any(ActionListener.class)); + + 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; + + String completeToken = "complete-token"; + ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); + when(bearerToken.getCompleteToken()).thenReturn(completeToken); + when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).indexTokenMetadata(any(ApiToken.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener() { + @Override + public void onResponse(String result) { + try { + assertThat(result, equalTo(completeToken)); + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(any()); + verify(securityTokenManager).issueApiToken(any(), any()); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) + ), + any(ActionListener.class) + ); + } finally { + super.onResponse(result); + } + } + }; + + doAnswer(invocation -> { + ActionListener l = invocation.getArgument(0); + l.onResponse(null); + return null; + }).when(apiTokenIndexHandler).createApiTokenIndexIfAbsent(any(ActionListener.class)); + + repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration, listener); + listener.assertSuccess(); + } + + @Test + public void testDeleteApiTokenThrowsApiTokenException() { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onFailure(new ApiTokenException("Token not found")); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("Token not found")); + } + + @Test + 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 testReloadApiTokensFromIndexAndParse() throws IOException { + // Setup mock response + Map expectedTokens = Map.of( + "test", + new ApiToken("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..922bfaff1e --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.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.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiTokenTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testIndexPermissionToStringFromString() throws IOException { + String indexPermissionString = "{\"index_pattern\":[\"index1\",\"index2\"],\"allowed_actions\":[\"action1\",\"action2\"]}"; + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission( + Arrays.asList("index1", "index2"), + Arrays.asList("action1", "action2") + ); + assertThat( + indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString(), + equalTo(indexPermissionString) + ); + + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, indexPermissionString); + + ApiToken.IndexPermission indexPermissionFromString = ApiToken.IndexPermission.fromXContent(parser); + assertThat(indexPermissionFromString.getIndexPatterns(), equalTo(List.of("index1", "index2"))); + assertThat(indexPermissionFromString.getAllowedActions(), equalTo(List.of("action1", "action2"))); + } + +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index e0026155de..2ab7b9da8e 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -27,6 +27,17 @@ public class AuthTokenUtilsTest { + @Test + public void testIsAccessToRestrictedEndpointsForApiToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/apitokens") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + @Test public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 32bcdf2b60..765d6daf54 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.ApiJwtClaimsBuilder; 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.") @@ -224,4 +225,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 ApiJwtClaimsBuilder().issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); + + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); + + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("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 7558533656..622c2af94e 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -110,6 +110,7 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { final Settings settings = Settings.builder().put("enabled", false).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); tokenManager.onDynamicConfigModelChanged(dcm); assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); @@ -126,6 +127,7 @@ private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncr .build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); tokenManager.onDynamicConfigModelChanged(dcm); return dcm; @@ -337,4 +339,25 @@ public void testCreateJwtWithBadRoles() { }); assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); } + + @Test + public void issueApiToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(false); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } } diff --git a/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java b/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java index 787d35e285..feebe83425 100644 --- a/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java +++ b/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java @@ -23,6 +23,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.configuration.ConfigurationRepository; @@ -39,6 +40,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -171,7 +173,8 @@ public void setUp() { settings, privilegesInterceptor, clusterInfoHolder, - irr + irr, + mock(ApiTokenRepository.class) ); } diff --git a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java index 85d4a9cfa1..53cffe360c 100644 --- a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java @@ -31,6 +31,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.NullAuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.securityconf.ConfigModel; @@ -168,7 +169,8 @@ PrivilegesEvaluator createPrivilegesEvaluator(SecurityDynamicConfiguration 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()); + } + } + } +} diff --git a/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java new file mode 100644 index 0000000000..8e92ce3a39 --- /dev/null +++ b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; +import static org.junit.Assert.assertThrows; + +public class ParsingUtilsTest { + + @Test + public void testSafeStringList() { + List emptyResult = safeStringList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + List result = safeStringList(Arrays.asList("test1", "test2"), "test_field"); + assertThat(result, is(Arrays.asList("test1", "test2"))); + + // Not a list + assertThrows(IllegalArgumentException.class, () -> safeStringList("not a list", "test_field")); + + // List with non-string + assertThrows(IllegalArgumentException.class, () -> safeStringList(Arrays.asList("test", 123), "test_field")); + } + + @Test + public void testSafeMapList() { + List> emptyResult = safeMapList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + Map map1 = new HashMap<>(); + map1.put("key1", "value1"); + map1.put("key2", 123); + + Map map2 = new HashMap<>(); + map2.put("key3", "value3"); + map2.put("key4", true); + + List> input = Arrays.asList(map1, map2); + List> result = safeMapList(input, "test_field"); + assertThat(result, is(input)); + + // Test not a list + assertThrows(IllegalArgumentException.class, () -> safeMapList("not a list", "test_field")); + + // Test list with non-map element + assertThrows(IllegalArgumentException.class, () -> safeMapList(Arrays.asList(map1, "not a map"), "test_field")); + + List> list = safeMapList(Arrays.asList(map1, map2), "test_field"); + assertThat(list.size(), is(2)); + assertThat(list.contains(map1), is(true)); + assertThat(list.contains(map2), is(true)); + + } + +}