diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java index a9f6cf9b1e..043d3908e9 100644 --- a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java +++ b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java @@ -68,7 +68,7 @@ public void shouldLoadDefaultConfiguration() { Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); } try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) { - client.assertCorrectCredentials(ADMIN_USER_NAME); + client.confirmCorrectCredentials(ADMIN_USER_NAME); HttpResponse response = client.get("/_plugins/_security/api/internalusers"); response.assertStatusCode(200); Map users = response.getBodyAs(Map.class); diff --git a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationTests.java b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationTests.java index b35495e23e..cc95f191f7 100644 --- a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationTests.java +++ b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationTests.java @@ -97,10 +97,10 @@ public void shouldCreateUserViaRestApi_success() { assertThat(httpResponse.getStatusCode(), equalTo(201)); } try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { - client.assertCorrectCredentials(USER_ADMIN.getName()); + client.confirmCorrectCredentials(USER_ADMIN.getName()); } try (TestRestClient client = cluster.getRestClient(ADDITIONAL_USER_1, ADDITIONAL_PASSWORD_1)) { - client.assertCorrectCredentials(ADDITIONAL_USER_1); + client.confirmCorrectCredentials(ADDITIONAL_USER_1); } } @@ -160,10 +160,10 @@ public void shouldCreateUserViaRestApiWhenAdminIsAuthenticatedViaCertificate_pos httpResponse.assertStatusCode(201); } try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { - client.assertCorrectCredentials(USER_ADMIN.getName()); + client.confirmCorrectCredentials(USER_ADMIN.getName()); } try (TestRestClient client = cluster.getRestClient(ADDITIONAL_USER_2, ADDITIONAL_PASSWORD_2)) { - client.assertCorrectCredentials(ADDITIONAL_USER_2); + client.confirmCorrectCredentials(ADDITIONAL_USER_2); } } @@ -189,10 +189,10 @@ public void shouldStillWorkAfterUpdateOfSecurityConfig() { cluster.updateUserConfiguration(users); try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { - client.assertCorrectCredentials(USER_ADMIN.getName()); + client.confirmCorrectCredentials(USER_ADMIN.getName()); } try (TestRestClient client = cluster.getRestClient(newUser)) { - client.assertCorrectCredentials(newUser.getName()); + client.confirmCorrectCredentials(newUser.getName()); } } diff --git a/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java index 7c4d05b714..975ce25efb 100644 --- a/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java @@ -89,7 +89,7 @@ public void shouldAuthenticateUserWithCertificate_positiveUserSpoke() { CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_BRIDGE, USER_SPOCK); try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { - client.assertCorrectCredentials(USER_SPOCK); + client.confirmCorrectCredentials(USER_SPOCK); } } @@ -98,7 +98,7 @@ public void shouldAuthenticateUserWithCertificate_positiveUserKirk() { CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_BRIDGE, USER_KIRK); try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { - client.assertCorrectCredentials(USER_KIRK); + client.confirmCorrectCredentials(USER_KIRK); } } diff --git a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java index 3d92cbf85e..1e3146c1e9 100644 --- a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -11,28 +11,27 @@ package org.opensearch.security.http; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; -import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; -import javax.crypto.SecretKey; - import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import org.apache.http.Header; import org.apache.http.HttpStatus; import org.apache.http.message.BasicHeader; -import org.junit.Assert; -import io.jsonwebtoken.security.Keys; +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.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.test.framework.OnBehalfOfConfig; import org.opensearch.test.framework.RolesMapping; @@ -42,12 +41,12 @@ import org.opensearch.test.framework.cluster.TestRestClient; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.aMapWithSize; -import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasKey; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.contains; import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; 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; @@ -60,6 +59,7 @@ public class OnBehalfOfJwtAuthenticationTest { static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); + private static final String CREATE_OBO_TOKEN_PATH = "_plugins/_security/api/generateonbehalfoftoken"; private static Boolean oboEnabled = true; private static final String signingKey = Base64.getEncoder() .encodeToString( @@ -67,6 +67,11 @@ public class OnBehalfOfJwtAuthenticationTest { StandardCharsets.UTF_8 ) ); + private static final String alternativeSigningKey = Base64.getEncoder() + .encodeToString( + "alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".getBytes(StandardCharsets.UTF_8) + ); + private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey".getBytes(StandardCharsets.UTF_8)); public static final String ADMIN_USER_NAME = "admin"; public static final String OBO_USER_NAME_WITH_PERM = "obo_user"; @@ -110,18 +115,36 @@ public class OnBehalfOfJwtAuthenticationTest { protected final static TestSecurityConfig.User HOST_MAPPING_OBO_USER = new TestSecurityConfig.User(OBO_USER_NAME_WITH_HOST_MAPPING) .roles(HOST_MAPPING_ROLE, ROLE_WITH_OBO_PERM); + private static OnBehalfOfConfig defaultOnBehalfOfConfig() { + return new OnBehalfOfConfig().oboEnabled(oboEnabled).signingKey(signingKey).encryptionKey(encryptionKey); + } + @ClassRule public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) .anonymousAuth(false) .users(ADMIN_USER, OBO_USER, OBO_USER_NO_PERM, HOST_MAPPING_OBO_USER) .nodeSettings( - Map.of(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true, SECURITY_RESTAPI_ROLES_ENABLED, List.of("user_admin__all_access")) + Map.of( + SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, + true, + SECURITY_RESTAPI_ROLES_ENABLED, + ADMIN_USER.getRoleNames(), + SECURITY_RESTAPI_ADMIN_ENABLED, + true, + "plugins.security.unsupported.restapi.allow_securityconfig_modification", + true + ) ) .authc(AUTHC_HTTPBASIC_INTERNAL) .rolesMapping(new RolesMapping(HOST_MAPPING_ROLE).hostIPs(HOST_MAPPING_IP)) - .onBehalfOf(new OnBehalfOfConfig().oboEnabled(oboEnabled).signingKey(signingKey).encryptionKey(encryptionKey)) + .onBehalfOf(defaultOnBehalfOfConfig()) .build(); + @Before + public void before() { + patchOnBehalfOfConfig(defaultOnBehalfOfConfig()); + } + @Test public void shouldAuthenticateWithOBOTokenEndPoint() { String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD); @@ -143,7 +166,7 @@ public void shouldNotAuthenticateForUsingOBOTokenToAccessOBOEndpoint() { Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken); try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) { - TestRestClient.HttpResponse response = client.getOnBehalfOfToken(OBO_DESCRIPTION, adminOboAuthHeader); + TestRestClient.HttpResponse response = client.postJson(CREATE_OBO_TOKEN_PATH, OBO_DESCRIPTION); response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED); } } @@ -154,7 +177,7 @@ public void shouldNotAuthenticateForUsingOBOTokenToAccessAccountEndpoint() { Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken); try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) { - TestRestClient.HttpResponse response = client.changeInternalUserPassword(CURRENT_AND_NEW_PASSWORDS, adminOboAuthHeader); + TestRestClient.HttpResponse response = client.putJson("_plugins/_security/api/account", CURRENT_AND_NEW_PASSWORDS); response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED); } } @@ -177,54 +200,84 @@ public void shouldNotAuthenticateForNonAdminUserWithoutOBOPermission() { public void shouldNotIncludeRolesFromHostMappingInOBOToken() { String oboToken = generateOboToken(OBO_USER_NAME_WITH_HOST_MAPPING, DEFAULT_PASSWORD); - SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKey)); - - Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(oboToken).getPayload(); + Claims claims = Jwts.parser().setSigningKey(Base64.getDecoder().decode(signingKey)).build().parseClaimsJws(oboToken).getBody(); Object er = claims.get("er"); EncryptionDecryptionUtil encryptionDecryptionUtil = new EncryptionDecryptionUtil(encryptionKey); String rolesClaim = encryptionDecryptionUtil.decrypt(er.toString()); - List roles = Arrays.stream(rolesClaim.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toUnmodifiableList()); + Set roles = Arrays.stream(rolesClaim.split(",")).map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet()); - Assert.assertFalse(roles.contains("host_mapping_role")); + assertThat(roles, equalTo(HOST_MAPPING_OBO_USER.getRoleNames())); + assertThat(roles, not(contains("host_mapping_role"))); } @Test public void shouldNotAuthenticateWithInvalidDurationSeconds() { try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) { - client.assertCorrectCredentials(ADMIN_USER_NAME); + client.confirmCorrectCredentials(ADMIN_USER_NAME); TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_DESCRIPTION_WITH_INVALID_DURATIONSECONDS); response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); - Map oboEndPointResponse = (Map) response.getBodyAs(Map.class); - assertTrue(oboEndPointResponse.containsValue("durationSeconds must be an integer.")); + assertThat(response.getTextFromJsonBody("/error"), equalTo("durationSeconds must be a number.")); } } @Test public void shouldNotAuthenticateWithInvalidAPIParameter() { try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) { - client.assertCorrectCredentials(ADMIN_USER_NAME); + client.confirmCorrectCredentials(ADMIN_USER_NAME); TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_DESCRIPTION_WITH_INVALID_PARAMETERS); response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); - Map oboEndPointResponse = (Map) response.getBodyAs(Map.class); - assertTrue(oboEndPointResponse.containsValue("Unrecognized parameter: invalidParameter")); + assertThat(response.getTextFromJsonBody("/error"), equalTo("Unrecognized parameter: invalidParameter")); } } + @Test + public void shouldNotAllowTokenWhenOboIsDisabled() { + final String oboToken = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeader = new BasicHeader("Authorization", "Bearer " + oboToken); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Disable OBO via config and see that the authenticator doesn't authorize + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().oboEnabled(false)); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + + // Reenable OBO via config and see that the authenticator is working again + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().oboEnabled(true)); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + } + + @Test + public void oboSigningCheckChangeIsDetected() { + final String oboTokenOrignalKey = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeaderOriginalKey = new BasicHeader("Authorization", "Bearer " + oboTokenOrignalKey); + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Change the signing key + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().signingKey(alternativeSigningKey)); + + // Original key should no longer work + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + + // Generate new key, check that it is valid + final String oboTokenOtherKey = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeaderOtherKey = new BasicHeader("Authorization", "Bearer " + oboTokenOtherKey); + authenticateWithOboToken(oboHeaderOtherKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Change back to the original signing key and the original key still works, and the new key doesn't + patchOnBehalfOfConfig(defaultOnBehalfOfConfig()); + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + authenticateWithOboToken(oboHeaderOtherKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + } + private String generateOboToken(String username, String password) { try (TestRestClient client = cluster.getRestClient(username, password)) { - client.assertCorrectCredentials(username); + client.confirmCorrectCredentials(username); TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_TOKEN_REASON); response.assertStatusCode(HttpStatus.SC_OK); - Map oboEndPointResponse = (Map) response.getBodyAs(Map.class); - assertThat( - oboEndPointResponse, - allOf(aMapWithSize(3), hasKey("user"), hasKey("authenticationToken"), hasKey("durationSeconds")) - ); - return oboEndPointResponse.get("authenticationToken").toString(); + assertThat(response.getTextFromJsonBody("/user"), notNullValue()); + assertThat(response.getTextFromJsonBody("/authenticationToken"), notNullValue()); + assertThat(response.getTextFromJsonBody("/durationSeconds"), notNullValue()); + return response.getTextFromJsonBody("/authenticationToken").toString(); } } @@ -239,4 +292,19 @@ private void authenticateWithOboToken(Header authHeader, String expectedUsername } } } + + private void patchOnBehalfOfConfig(final OnBehalfOfConfig oboConfig) { + try (final TestRestClient adminClient = cluster.getRestClient(cluster.getAdminCertificate())) { + final XContentBuilder configBuilder = XContentFactory.jsonBuilder(); + configBuilder.value(oboConfig); + + final String patchBody = "[{ \"op\": \"replace\", \"path\": \"/config/dynamic/on_behalf_of\", \"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/security/http/ServiceAccountAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/ServiceAccountAuthenticationTest.java new file mode 100644 index 0000000000..f10971f22c --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/ServiceAccountAuthenticationTest.java @@ -0,0 +1,142 @@ +/* + * 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 com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opensearch.test.framework.TestIndex; +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 java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_KEY; +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 ServiceAccountAuthenticationTest { + + public static final String SERVICE_ATTRIBUTE = "service"; + + static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); + + public static final String SERVICE_ACCOUNT_USER_NAME = "test-service-account"; + + static final TestSecurityConfig.User SERVICE_ACCOUNT_ADMIN_USER = new TestSecurityConfig.User(SERVICE_ACCOUNT_USER_NAME).attr( + SERVICE_ATTRIBUTE, + "true" + ) + .roles( + new TestSecurityConfig.Role("test-service-account-role").clusterPermissions("*") + .indexPermissions("*", "system:admin/system_index") + .on("*") + ); + + private static final TestIndex TEST_NON_SYS_INDEX = TestIndex.name("test-non-sys-index") + .setting("index.number_of_shards", 1) + .setting("index.number_of_replicas", 0) + .build(); + + private static final TestIndex TEST_SYS_INDEX = TestIndex.name("test-sys-index") + .setting("index.number_of_shards", 1) + .setting("index.number_of_replicas", 0) + .build(); + + @ClassRule + public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .anonymousAuth(false) + .users(ADMIN_USER, SERVICE_ACCOUNT_ADMIN_USER) + .nodeSettings( + Map.of( + SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY, + true, + SECURITY_SYSTEM_INDICES_ENABLED_KEY, + true, + SECURITY_RESTAPI_ROLES_ENABLED, + List.of("user_admin__all_access"), + SECURITY_SYSTEM_INDICES_KEY, + List.of(TEST_SYS_INDEX.getName()) + ) + ) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .indices(TEST_NON_SYS_INDEX, TEST_SYS_INDEX) + .build(); + + @Test + public void testClusterHealthWithServiceAccountCred() { + try (TestRestClient client = cluster.getRestClient(SERVICE_ACCOUNT_ADMIN_USER)) { + client.confirmCorrectCredentials(SERVICE_ACCOUNT_USER_NAME); + TestRestClient.HttpResponse response = client.get("_cluster/health"); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + String responseBody = response.getBody(); + + assertNotNull("Response body should not be null", responseBody); + assertTrue(responseBody.contains("\"type\":\"security_exception\"")); + } + } + + @Test + public void testReadSysIndexWithServiceAccountCred() { + try (TestRestClient client = cluster.getRestClient(SERVICE_ACCOUNT_ADMIN_USER)) { + client.confirmCorrectCredentials(SERVICE_ACCOUNT_USER_NAME); + TestRestClient.HttpResponse response = client.get(TEST_SYS_INDEX.getName()); + response.assertStatusCode(HttpStatus.SC_OK); + + String responseBody = response.getBody(); + + assertNotNull("Response body should not be null", responseBody); + assertTrue(responseBody.contains(TEST_SYS_INDEX.getName())); + } + } + + @Test + public void testReadNonSysIndexWithServiceAccountCred() { + try (TestRestClient client = cluster.getRestClient(SERVICE_ACCOUNT_ADMIN_USER)) { + client.confirmCorrectCredentials(SERVICE_ACCOUNT_USER_NAME); + TestRestClient.HttpResponse response = client.get(TEST_NON_SYS_INDEX.getName()); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + String responseBody = response.getBody(); + + assertNotNull("Response body should not be null", responseBody); + assertTrue(responseBody.contains("\"type\":\"security_exception\"")); + } + } + + @Test + public void testReadBothWithServiceAccountCred() { + TestRestClient client = cluster.getRestClient(SERVICE_ACCOUNT_ADMIN_USER); + client.confirmCorrectCredentials(SERVICE_ACCOUNT_USER_NAME); + TestRestClient.HttpResponse response = client.get((TEST_SYS_INDEX.getName() + "," + TEST_NON_SYS_INDEX.getName())); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + String responseBody = response.getBody(); + + assertNotNull("Response body should not be null", responseBody); + assertTrue(responseBody.contains("\"type\":\"security_exception\"")); + + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index 21cfd7fdaf..aea4dac4fe 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -126,29 +126,7 @@ public HttpResponse getAuthInfo(Header... headers) { return executeRequest(new HttpGet(getHttpServerUri() + "/_opendistro/_security/authinfo?pretty"), headers); } - public HttpResponse getOnBehalfOfToken(String jsonData, Header... headers) { - try { - HttpPost httpPost = new HttpPost( - new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/generateonbehalfoftoken?pretty").build() - ); - httpPost.setEntity(toStringEntity(jsonData)); - return executeRequest(httpPost, mergeHeaders(CONTENT_TYPE_JSON, headers)); - } catch (URISyntaxException ex) { - throw new RuntimeException("Incorrect URI syntax", ex); - } - } - - public HttpResponse changeInternalUserPassword(String jsonData, Header... headers) { - try { - HttpPut httpPut = new HttpPut(new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/account?pretty").build()); - httpPut.setEntity(toStringEntity(jsonData)); - return executeRequest(httpPut, mergeHeaders(CONTENT_TYPE_JSON, headers)); - } catch (URISyntaxException ex) { - throw new RuntimeException("Incorrect URI syntax", ex); - } - } - - public void assertCorrectCredentials(String expectedUserName) { + public void confirmCorrectCredentials(String expectedUserName) { HttpResponse response = getAuthInfo(); assertThat(response, notNullValue()); response.assertStatusCode(200); diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 55d131209e..f8cd1ce2ee 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -55,6 +55,7 @@ import java.util.stream.Stream; import com.google.common.collect.Lists; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.lucene.search.QueryCachingPolicy; @@ -100,11 +101,15 @@ import org.opensearch.http.HttpServerTransport; import org.opensearch.http.HttpServerTransport.Dispatcher; import org.opensearch.core.index.Index; +import org.opensearch.identity.Subject; +import org.opensearch.identity.noop.NoopSubject; import org.opensearch.index.IndexModule; import org.opensearch.index.cache.query.QueryCache; import org.opensearch.indices.IndicesService; import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.plugins.ClusterPlugin; +import org.opensearch.plugins.ExtensionAwarePlugin; +import org.opensearch.plugins.IdentityPlugin; import org.opensearch.plugins.MapperPlugin; import org.opensearch.repositories.RepositoriesService; import org.opensearch.rest.RestController; @@ -146,6 +151,7 @@ import org.opensearch.security.http.SecurityHttpServerTransport; import org.opensearch.security.http.SecurityNonSslHttpServerTransport; import org.opensearch.security.http.XFFResolver; +import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.PrivilegesInterceptor; import org.opensearch.security.privileges.RestLayerPrivilegesEvaluator; @@ -199,7 +205,16 @@ import static org.opensearch.security.setting.DeprecatedSettings.checkForDeprecatedSetting; import static org.opensearch.security.support.ConfigConstants.SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION; -public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin implements ClusterPlugin, MapperPlugin { +public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin + implements + ClusterPlugin, + MapperPlugin, + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + ExtensionAwarePlugin, + IdentityPlugin +// CS-ENFORCE-SINGLE + +{ private static final String KEYWORD = ".keyword"; private static final Logger actionTrace = LogManager.getLogger("opendistro_security_action_trace"); @@ -223,6 +238,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile SslExceptionHandler sslExceptionHandler; private volatile Client localClient; private final boolean disabled; + private volatile SecurityTokenManager tokenManager; private volatile DynamicConfigFactory dcf; private final List demoCertHashes = new ArrayList(3); private volatile SecurityFilter sf; @@ -550,9 +566,7 @@ public List getRestHandlers( principalExtractor ) ); - CreateOnBehalfOfTokenAction cobot = new CreateOnBehalfOfTokenAction(settings, threadPool, Objects.requireNonNull(cs)); - dcf.registerDCFListener(cobot); - handlers.add(cobot); + handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -1024,6 +1038,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); + tokenManager = new SecurityTokenManager(cs, threadPool, userService); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1074,6 +1089,7 @@ public Collection createComponents( dcf.registerDCFListener(evaluator); dcf.registerDCFListener(restLayerEvaluator); dcf.registerDCFListener(securityRestHandler); + dcf.registerDCFListener(tokenManager); if (!(auditLog instanceof NullAuditLog)) { // Don't register if advanced modules is disabled in which case auditlog is instance of NullAuditLog dcf.registerDCFListener(auditLog); @@ -1925,6 +1941,17 @@ private static String handleKeyword(final String field) { return field; } + @Override + public Subject getSubject() { + // Not supported + return new NoopSubject(); + } + + @Override + public SecurityTokenManager getTokenManager() { + return tokenManager; + } + public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; diff --git a/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java index fe58e2adb1..0863fee552 100644 --- a/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java +++ b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java @@ -13,34 +13,23 @@ import java.io.IOException; import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Set; -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.greenrobot.eventbus.Subscribe; import org.opensearch.client.node.NodeClient; -import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.settings.Settings; import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.rest.BaseRestHandler; import org.opensearch.rest.BytesRestResponse; import org.opensearch.rest.NamedRoute; import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestRequest; import org.opensearch.core.rest.RestStatus; -import org.opensearch.security.authtoken.jwt.JwtVendor; -import org.opensearch.security.securityconf.ConfigModel; -import org.opensearch.security.securityconf.DynamicConfigModel; -import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.user.User; -import org.opensearch.threadpool.ThreadPool; +import org.opensearch.security.identity.SecurityTokenManager; import static org.opensearch.rest.RestRequest.Method.POST; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; @@ -52,50 +41,16 @@ public class CreateOnBehalfOfTokenAction extends BaseRestHandler { "/_plugins/_security/api" ); - private JwtVendor vendor; - private final ThreadPool threadPool; - private final ClusterService clusterService; - - private ConfigModel configModel; - - private DynamicConfigModel dcm; - - public static final Integer OBO_DEFAULT_EXPIRY_SECONDS = 5 * 60; - public static final Integer OBO_MAX_EXPIRY_SECONDS = 10 * 60; - + public static final long OBO_DEFAULT_EXPIRY_SECONDS = 5 * 60; + public static final long OBO_MAX_EXPIRY_SECONDS = 10 * 60; public static final String DEFAULT_SERVICE = "self-issued"; - protected final Logger log = LogManager.getLogger(this.getClass()); - - private static final Set RECOGNIZED_PARAMS = new HashSet<>( - Arrays.asList("durationSeconds", "description", "roleSecurityMode", "service") - ); - - @Subscribe - public void onConfigModelChanged(ConfigModel configModel) { - this.configModel = configModel; - } - - @Subscribe - public void onDynamicConfigModelChanged(DynamicConfigModel dcm) { - this.dcm = dcm; - - Settings settings = dcm.getDynamicOnBehalfOfSettings(); + private static final Logger LOG = LogManager.getLogger(CreateOnBehalfOfTokenAction.class); - Boolean enabled = Boolean.parseBoolean(settings.get("enabled")); - String signingKey = settings.get("signing_key"); - String encryptionKey = settings.get("encryption_key"); + private final SecurityTokenManager securityTokenManager; - if (!Boolean.FALSE.equals(enabled) && signingKey != null && encryptionKey != null) { - this.vendor = new JwtVendor(settings, Optional.empty()); - } else { - this.vendor = null; - } - } - - public CreateOnBehalfOfTokenAction(final Settings settings, final ThreadPool threadPool, final ClusterService clusterService) { - this.threadPool = threadPool; - this.clusterService = clusterService; + public CreateOnBehalfOfTokenAction(final SecurityTokenManager securityTokenManager) { + this.securityTokenManager = securityTokenManager; } @Override @@ -109,7 +64,7 @@ public List routes() { } @Override - protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { switch (request.method()) { case POST: return handlePost(request, client); @@ -118,64 +73,46 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli } } - private RestChannelConsumer handlePost(RestRequest request, NodeClient client) throws IOException { + private RestChannelConsumer handlePost(final RestRequest request, final NodeClient client) throws IOException { return new RestChannelConsumer() { @Override - public void accept(RestChannel channel) throws Exception { + public void accept(final RestChannel channel) throws Exception { final XContentBuilder builder = channel.newBuilder(); BytesRestResponse response; try { - if (vendor == null) { + if (!securityTokenManager.issueOnBehalfOfTokenAllowed()) { channel.sendResponse( new BytesRestResponse( - RestStatus.SERVICE_UNAVAILABLE, + RestStatus.BAD_REQUEST, "The OnBehalfOf token generating API has been disabled, see {link to doc} for more information on this feature." /* TODO: Update the link to the documentation website */ ) ); return; } - final String clusterIdentifier = clusterService.getClusterName().value(); - final Map requestBody = request.contentOrSourceParamParser().map(); validateRequestParameters(requestBody); - Integer tokenDuration = parseAndValidateDurationSeconds(requestBody.get("durationSeconds")); + long tokenDuration = parseAndValidateDurationSeconds(requestBody.get(InputParameters.DURATION.paramName)); tokenDuration = Math.min(tokenDuration, OBO_MAX_EXPIRY_SECONDS); - final String description = (String) requestBody.getOrDefault("description", null); - - final Boolean roleSecurityMode = Optional.ofNullable(requestBody.get("roleSecurityMode")) - .map(value -> (Boolean) value) - .orElse(true); // Default to false if null - - final String service = (String) requestBody.getOrDefault("service", DEFAULT_SERVICE); - final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); - Set mappedRoles = mapRoles(user); + final String description = (String) requestBody.getOrDefault(InputParameters.DESCRIPTION.paramName, null); + final String service = (String) requestBody.getOrDefault(InputParameters.SERVICE.paramName, DEFAULT_SERVICE); + final var token = securityTokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims(service, tokenDuration)); builder.startObject(); - builder.field("user", user.getName()); - - final String token = vendor.createJwt( - clusterIdentifier, - user.getName(), - service, - tokenDuration, - mappedRoles.stream().collect(Collectors.toList()), - user.getRoles().stream().collect(Collectors.toList()), - roleSecurityMode - ); - builder.field("authenticationToken", token); - builder.field("durationSeconds", tokenDuration); + builder.field("user", token.getSubject()); + builder.field("authenticationToken", token.getCompleteToken()); + builder.field("durationSeconds", token.getExpiresInSeconds()); builder.endObject(); response = new BytesRestResponse(RestStatus.OK, builder); - } catch (IllegalArgumentException iae) { + } catch (final IllegalArgumentException iae) { builder.startObject().field("error", iae.getMessage()).endObject(); response = new BytesRestResponse(RestStatus.BAD_REQUEST, builder); } catch (final Exception exception) { - log.error("Unexpected error occurred: ", exception); + LOG.error("Unexpected error occurred: ", exception); builder.startObject().field("error", "An unexpected error occurred. Please check the input and try again.").endObject(); @@ -187,19 +124,28 @@ public void accept(RestChannel channel) throws Exception { }; } - private Set mapRoles(final User user) { - return this.configModel.mapSecurityRoles(user, null); + private enum InputParameters { + DURATION("durationSeconds"), + DESCRIPTION("description"), + SERVICE("service"); + + final String paramName; + + private InputParameters(final String paramName) { + this.paramName = paramName; + } } - private void validateRequestParameters(Map requestBody) throws IllegalArgumentException { - for (String key : requestBody.keySet()) { - if (!RECOGNIZED_PARAMS.contains(key)) { - throw new IllegalArgumentException("Unrecognized parameter: " + key); - } + private void validateRequestParameters(final Map requestBody) throws IllegalArgumentException { + for (final String key : requestBody.keySet()) { + Arrays.stream(InputParameters.values()) + .filter(param -> param.paramName.equalsIgnoreCase(key)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("Unrecognized parameter: " + key)); } } - private Integer parseAndValidateDurationSeconds(Object durationObj) throws IllegalArgumentException { + private long parseAndValidateDurationSeconds(final Object durationObj) throws IllegalArgumentException { if (durationObj == null) { return OBO_DEFAULT_EXPIRY_SECONDS; } @@ -208,9 +154,9 @@ private Integer parseAndValidateDurationSeconds(Object durationObj) throws Illeg return (Integer) durationObj; } else if (durationObj instanceof String) { try { - return Integer.parseInt((String) durationObj); - } catch (NumberFormatException ignored) {} + return Long.parseLong((String) durationObj); + } catch (final NumberFormatException ignored) {} } - throw new IllegalArgumentException("durationSeconds must be an integer."); + throw new IllegalArgumentException("durationSeconds must be a number."); } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java new file mode 100644 index 0000000000..a0879cd4da --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java @@ -0,0 +1,40 @@ +/* + * 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; + +import java.util.Date; + +import org.opensearch.identity.tokens.BearerAuthToken; + +public class ExpiringBearerAuthToken extends BearerAuthToken { + private final String subject; + private final Date expiry; + private final long expiresInSeconds; + + public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry, final long expiresInSeconds) { + super(serializedToken); + this.subject = subject; + this.expiry = expiry; + this.expiresInSeconds = expiresInSeconds; + } + + public String getSubject() { + return subject; + } + + public Date getExpiry() { + return expiry; + } + + public long getExpiresInSeconds() { + return expiresInSeconds; + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 5500eb5588..9ab6742cf9 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,6 +11,8 @@ package org.opensearch.security.authtoken.jwt; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.text.ParseException; import java.util.Base64; import java.util.Date; @@ -46,7 +48,6 @@ public class JwtVendor { private final JWSSigner signer; private final LongSupplier timeProvider; private final EncryptionDecryptionUtil encryptionDecryptionUtil; - private static final Integer DEFAULT_EXPIRY_SECONDS = 300; private static final Integer MAX_EXPIRY_SECONDS = 600; public JwtVendor(final Settings settings, final Optional timeProvider) { @@ -59,11 +60,7 @@ public JwtVendor(final Settings settings, final Optional timeProvi } else { this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(settings.get("encryption_key")); } - if (timeProvider.isPresent()) { - this.timeProvider = timeProvider.get(); - } else { - this.timeProvider = () -> System.currentTimeMillis(); - } + this.timeProvider = timeProvider.orElse(System::currentTimeMillis); } /* @@ -72,15 +69,15 @@ public JwtVendor(final Settings settings, final Optional timeProvi * PublicKeyUse: SIGN * Encryption Algorithm: HS512 * */ - static Tuple createJwkFromSettings(Settings settings) { + static Tuple createJwkFromSettings(final Settings settings) { final OctetSequenceKey key; if (!isKeyNull(settings, "signing_key")) { - String signingKey = settings.get("signing_key"); + final String signingKey = settings.get("signing_key"); key = new OctetSequenceKey.Builder(Base64.getDecoder().decode(signingKey)).algorithm(JWSAlgorithm.HS512) .keyUse(KeyUse.SIGNATURE) .build(); } else { - Settings jwkSettings = settings.getAsSettings("jwt").getAsSettings("key"); + final Settings jwkSettings = settings.getAsSettings("jwt").getAsSettings("key"); if (jwkSettings.isEmpty()) { throw new OpenSearchException( @@ -88,7 +85,7 @@ static Tuple createJwkFromSettings(Settings settings) { ); } - String signingKey = jwkSettings.get("k"); + final String signingKey = jwkSettings.get("k"); key = new OctetSequenceKey.Builder(Base64.getDecoder().decode(signingKey)).algorithm(JWSAlgorithm.HS512) .keyUse(KeyUse.SIGNATURE) .build(); @@ -96,66 +93,77 @@ static Tuple createJwkFromSettings(Settings settings) { try { return new Tuple<>(key, new MACSigner(key)); - } catch (KeyLengthException kle) { + } catch (final KeyLengthException kle) { throw new OpenSearchException(kle); } } - public String createJwt( - String issuer, - String subject, - String audience, - Integer expirySeconds, - List roles, - List backendRoles, - boolean roleSecurityMode - ) throws JOSEException, ParseException { - final Date now = new Date(timeProvider.getAsLong()); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - if (expirySeconds > MAX_EXPIRY_SECONDS) { - throw new IllegalArgumentException( - "The provided expiration time exceeds the maximum allowed duration of " + MAX_EXPIRY_SECONDS + " seconds" - ); - } - - expirySeconds = (expirySeconds == null) ? DEFAULT_EXPIRY_SECONDS : Math.min(expirySeconds, MAX_EXPIRY_SECONDS); - if (expirySeconds <= 0) { - throw new IllegalArgumentException("The expiration time should be a positive integer"); - } - final Date expiryTime = new Date(timeProvider.getAsLong() + expirySeconds * 1000); - claimsBuilder.expirationTime(expiryTime); - - if (roles != null) { - String listOfRoles = String.join(",", roles); - claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); - } else { - throw new IllegalArgumentException("Roles cannot be null"); - } - - if (!roleSecurityMode && backendRoles != null) { - String listOfBackendRoles = String.join(",", backendRoles); - claimsBuilder.claim("br", listOfBackendRoles); - } - - final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = new SignedJWT(header, claimsBuilder.build()); - - // Sign the JWT so it can be serialized - signedJwt.sign(signer); - - if (logger.isDebugEnabled()) { - logger.debug( - "Created JWT: " + signedJwt.serialize() + "\n" + signedJwt.getHeader().toJSONObject() + "\n" + signedJwt.getJWTClaimsSet() - ); - } - - return signedJwt.serialize(); + public ExpiringBearerAuthToken createJwt( + final String issuer, + final String subject, + final String audience, + final long requestedExpirySeconds, + final List roles, + final List backendRoles, + final boolean includeBackendRoles + ) { + return AccessController.doPrivileged(new PrivilegedAction() { + @Override + public ExpiringBearerAuthToken run() { + try { + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); + claimsBuilder.issuer(issuer); + claimsBuilder.issueTime(now); + claimsBuilder.subject(subject); + claimsBuilder.audience(audience); + claimsBuilder.notBeforeTime(now); + + final long expirySeconds = Math.min(requestedExpirySeconds, MAX_EXPIRY_SECONDS); + if (expirySeconds <= 0) { + throw new IllegalArgumentException("The expiration time should be a positive integer"); + } + final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); + claimsBuilder.expirationTime(expiryTime); + + if (roles != null) { + final String listOfRoles = String.join(",", roles); + claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); + } else { + throw new IllegalArgumentException("Roles cannot be null"); + } + + if (includeBackendRoles && backendRoles != null) { + final String listOfBackendRoles = String.join(",", backendRoles); + claimsBuilder.claim("br", listOfBackendRoles); + } + + final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); + final SignedJWT signedJwt = new SignedJWT(header, claimsBuilder.build()); + + // Sign the JWT so it can be serialized + signedJwt.sign(signer); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + + signedJwt.serialize() + + "\n" + + signedJwt.getHeader().toJSONObject() + + "\n" + + signedJwt.getJWTClaimsSet().toJSONObject() + ); + } + + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); + + } catch (JOSEException | ParseException e) { + logger.error("Error while creating JWT token", e); + throw new OpenSearchException("Error while creating JWT token", e); + } + } + }); } } diff --git a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java index 24f9b80a39..38aa20b950 100644 --- a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java +++ b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java @@ -461,7 +461,7 @@ public Map> getConfigurationsFromIndex( throw new OpenSearchException(e); } - if (logComplianceEvent && auditLog.getComplianceConfig().isEnabled()) { + if (logComplianceEvent && auditLog.getComplianceConfig() != null && auditLog.getComplianceConfig().isEnabled()) { CType configurationType = configTypes.iterator().next(); Map fields = new HashMap(); fields.put(configurationType.toLCString(), Strings.toString(MediaTypeRegistry.JSON, retVal.get(configurationType))); diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java index 7b9a832a21..449762c8ff 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java @@ -28,7 +28,9 @@ import org.opensearch.security.dlic.rest.validation.ValidationResult; import org.opensearch.security.securityconf.Hashed; import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.support.SecurityJsonNode; +import org.opensearch.security.user.UserFilterType; import org.opensearch.security.user.UserService; import org.opensearch.security.user.UserServiceException; import org.opensearch.threadpool.ThreadPool; @@ -48,6 +50,12 @@ public class InternalUsersApiAction extends AbstractApiAction { + @Override + protected void consumeParameters(final RestRequest request) { + request.param("name"); + request.param("filterBy"); + } + static final List RESTRICTED_FROM_USERNAME = ImmutableList.of( ":" // Not allowed in basic auth, see https://stackoverflow.com/a/33391003/533057 ); @@ -96,7 +104,13 @@ protected CType getConfigType() { } private void internalUsersApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) { - requestHandlersBuilder + requestHandlersBuilder.onGetRequest( + request -> ValidationResult.success(request).map(this::processGetRequest).map(securityConfiguration -> { + final var configuration = securityConfiguration.configuration(); + filterUsers(configuration, filterParam(request)); + return ValidationResult.success(securityConfiguration); + }) + ) // Overrides the GET request functionality to allow for the special case of requesting an auth token. .override( Method.POST, @@ -121,6 +135,21 @@ private void internalUsersApiRequestHandlers(RequestHandler.RequestHandlersBuild .map(this::validateAndUpdatePassword) .map(this::addEntityToConfig) ); + + } + + protected final ValidationResult filterUsers(SecurityDynamicConfiguration users, UserFilterType userType) { + userService.includeAccountsIfType(users, userType); + return ValidationResult.success(SecurityConfiguration.of(users.getCType().toString(), users)); + + } + + protected final UserFilterType filterParam(final RestRequest request) { + final String filter = request.param("filterBy"); + if (Strings.isNullOrEmpty(filter)) { + return UserFilterType.ANY; + } + return UserFilterType.fromString(filter); } ValidationResult withAuthTokenPath(final RestRequest request) throws IOException { @@ -137,11 +166,7 @@ void generateAuthToken(final RestChannel channel, final SecurityConfiguration se try { final var username = securityConfiguration.entityName(); final var authToken = userService.generateAuthToken(username); - if (!Strings.isNullOrEmpty(authToken)) { - ok(channel, "'" + username + "' authtoken generated " + authToken); - } else { - badRequest(channel, "'" + username + "' authtoken failed to be created."); - } + ok(channel, "'" + username + "' authtoken generated " + authToken); } catch (final UserServiceException e) { badRequest(channel, e.getMessage()); } @@ -179,8 +204,8 @@ ValidationResult createOrUpdateAccount( try { final var username = securityConfiguration.entityName(); final var content = (ObjectNode) securityConfiguration.requestContent(); - if (request.hasParam("service")) { - content.put("service", request.param("service")); + if (request.hasParam("attributes")) { + content.put("attributes", request.param("attributes")); } if (request.hasParam("enabled")) { content.put("enabled", request.param("enabled")); diff --git a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java index e4b436ef0b..da9099cb8a 100644 --- a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -11,17 +11,6 @@ package org.opensearch.security.http; -import java.security.AccessController; -import java.security.PrivilegedAction; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.Map.Entry; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParserBuilder; @@ -29,7 +18,6 @@ import org.apache.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; @@ -43,12 +31,24 @@ import org.opensearch.security.user.AuthCredentials; import org.opensearch.security.util.KeyUtils; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + 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 OnBehalfOfAuthenticator 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); @@ -73,24 +73,30 @@ public OnBehalfOfAuthenticator(Settings settings, String clusterName) { 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(); - } + jwtParser = AccessController.doPrivileged((PrivilegedAction) () -> { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); }); - this.clusterName = clusterName; this.encryptionUtil = new EncryptionDecryptionUtil(encryptionKey); } private JwtParserBuilder initParserBuilder(final String signingKey) { - JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find on behalf of authenticator signing_key"); + } - if (jwtParserBuilder == null) { - throw new OpenSearchSecurityException("Unable to find on behalf of 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; } diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java new file mode 100644 index 0000000000..9f4ffecf57 --- /dev/null +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -0,0 +1,141 @@ +/* + * 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.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import joptsimple.internal.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.greenrobot.eventbus.Subscribe; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.identity.Subject; +import org.opensearch.identity.noop.NoopSubject; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.OnBehalfOfClaims; +import org.opensearch.identity.tokens.TokenManager; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.securityconf.ConfigModel; +import org.opensearch.security.securityconf.DynamicConfigModel; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.security.user.UserService; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class is the Security Plugin's implementation of the TokenManager used by all Identity Plugins. + * It handles the issuance of both Service Account Tokens and On Behalf Of tokens. + */ +public class SecurityTokenManager implements TokenManager { + private static final Logger logger = LogManager.getLogger(SecurityTokenManager.class); + + private final ClusterService cs; + private final ThreadPool threadPool; + private final UserService userService; + + private JwtVendor jwtVendor = null; + private ConfigModel configModel = null; + + public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { + this.cs = cs; + this.threadPool = threadPool; + this.userService = userService; + } + + @Subscribe + public void onConfigModelChanged(final ConfigModel configModel) { + this.configModel = configModel; + } + + @Subscribe + public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { + final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); + final Boolean enabled = oboSettings.getAsBoolean("enabled", false); + if (enabled) { + jwtVendor = createJwtVendor(oboSettings); + } else { + jwtVendor = null; + } + } + + /** For testing */ + JwtVendor createJwtVendor(final Settings settings) { + try { + return new JwtVendor(settings, Optional.empty()); + } catch (final Exception ex) { + logger.error("Unable to create the JwtVendor instance", ex); + return null; + } + } + + public boolean issueOnBehalfOfTokenAllowed() { + return jwtVendor != null && configModel != null; + } + + @Override + public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final OnBehalfOfClaims claims) { + if (!issueOnBehalfOfTokenAllowed()) { + // TODO: link that doc! + throw new OpenSearchSecurityException( + "The OnBehalfOf token generation is not enabled, see {link to doc} for more information on this feature." + ); + } + + if (subject != null && !(subject instanceof NoopSubject)) { + logger.warn("Unsupported subject for OnBehalfOfToken token generation, {}", subject); + throw new IllegalArgumentException("Unsupported subject to generate OnBehalfOfToken"); + } + + if (Strings.isNullOrEmpty(claims.getAudience())) { + throw new IllegalArgumentException("Claims must be supplied with an audience value"); + } + + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + if (user == null) { + throw new OpenSearchSecurityException("Unsupported user to generate OnBehalfOfToken"); + } + + final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ + final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + + try { + return jwtVendor.createJwt( + cs.getClusterName().value(), + user.getName(), + claims.getAudience(), + claims.getExpiration(), + mappedRoles.stream().collect(Collectors.toList()), + user.getRoles().stream().collect(Collectors.toList()), + false + ); + } catch (final Exception ex) { + logger.error("Error creating OnBehalfOfToken for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate OnBehalfOfToken"); + } + } + + @Override + public AuthToken issueServiceAccountToken(final String serviceId) { + try { + return userService.generateAuthToken(serviceId); + } catch (final Exception e) { + logger.error("Error creating sevice final account auth token, service " + serviceId, e); + throw new OpenSearchSecurityException("Unable to issue service account token"); + } + } +} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 73b872f206..ea517ce25d 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -353,7 +353,15 @@ public PrivilegesEvaluatorResponse evaluate( namedXContentRegistry ); + final boolean serviceAccountUser = user.isServiceAccount(); if (isClusterPerm(action0)) { + if (serviceAccountUser) { + presponse.missingPrivileges.add(action0); + presponse.allowed = false; + log.info("{} is a service account which doesn't have access to cluster level permission: {}", user, action0); + return presponse; + } + if (!securityRoles.impliesClusterPermissionPermission(action0)) { presponse.missingPrivileges.add(action0); presponse.allowed = false; diff --git a/src/main/java/org/opensearch/security/privileges/SecurityIndexAccessEvaluator.java b/src/main/java/org/opensearch/security/privileges/SecurityIndexAccessEvaluator.java index 3743b27383..4d5fb26050 100644 --- a/src/main/java/org/opensearch/security/privileges/SecurityIndexAccessEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/SecurityIndexAccessEvaluator.java @@ -199,6 +199,22 @@ private List getAllProtectedSystemIndices(final Resolved requestedResolv return new ArrayList<>(superAdminAccessOnlyIndexMatcher.getMatchAny(requestedResolved.getAllIndices(), Collectors.toList())); } + /** + * Checks if the request contains any regular (non-system and non-protected) indices. + * Regular indices are those that are not categorized as system indices or protected system indices. + * This method helps in identifying requests that might be accessing regular indices alongside system indices. + * @param requestedResolved The resolved object of the request, which contains the list of indices from the original request. + * @return true if the request contains any regular indices, false otherwise. + */ + private boolean requestContainsAnyRegularIndices(final Resolved requestedResolved) { + Set allIndices = requestedResolved.getAllIndices(); + + List allSystemIndices = getAllSystemIndices(requestedResolved); + List allProtectedSystemIndices = getAllProtectedSystemIndices(requestedResolved); + + return allIndices.stream().anyMatch(index -> !allSystemIndices.contains(index) && !allProtectedSystemIndices.contains(index)); + } + /** * Is the current action allowed to be performed on security index * @param action request action on security index @@ -233,8 +249,28 @@ private void evaluateSystemIndicesAccess( ) { // Perform access check is system index permissions are enabled boolean containsSystemIndex = requestContainsAnySystemIndices(requestedResolved); + boolean containsRegularIndex = requestContainsAnyRegularIndices(requestedResolved); + boolean serviceAccountUser = user.isServiceAccount(); if (isSystemIndexPermissionEnabled) { + if (serviceAccountUser && containsRegularIndex) { + auditLog.logSecurityIndexAttempt(request, action, task); + if (!containsSystemIndex && log.isInfoEnabled()) { + log.info("{} not permitted for a service account {} on non-system indices.", action, securityRoles); + } else if (containsSystemIndex && log.isDebugEnabled()) { + List regularIndices = requestedResolved.getAllIndices() + .stream() + .filter( + index -> !getAllSystemIndices(requestedResolved).contains(index) + && !getAllProtectedSystemIndices(requestedResolved).contains(index) + ) + .collect(Collectors.toList()); + log.debug("Service account cannot access regular indices: {}", regularIndices); + } + presponse.allowed = false; + presponse.markComplete(); + return; + } boolean containsProtectedIndex = requestContainsAnyProtectedSystemIndices(requestedResolved); if (containsProtectedIndex) { auditLog.logSecurityIndexAttempt(request, action, task); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java b/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java index 5c5e9b8ecd..938ee23c1e 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java @@ -30,6 +30,7 @@ import java.io.IOException; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -286,7 +287,11 @@ public SecurityDynamicConfiguration deepClone() { @JsonIgnore public void remove(String key) { centries.remove(key); + } + @JsonIgnore + public void remove(List keySet) { + keySet.stream().forEach(this::remove); } @SuppressWarnings({ "rawtypes", "unchecked" }) diff --git a/src/main/java/org/opensearch/security/user/User.java b/src/main/java/org/opensearch/security/user/User.java index 394b251271..aa9c09a469 100644 --- a/src/main/java/org/opensearch/security/user/User.java +++ b/src/main/java/org/opensearch/security/user/User.java @@ -286,4 +286,14 @@ public final Set getSecurityRoles() { ? Collections.synchronizedSet(Collections.emptySet()) : Collections.unmodifiableSet(this.securityRoles); } + + /** + * Check the custom attributes associated with this user + * + * @return true if it has a service account attributes. otherwise false + */ + public boolean isServiceAccount() { + Map userAttributesMap = this.getCustomAttributesMap(); + return userAttributesMap != null && "true".equals(userAttributesMap.get("attr.internal.service")); + } } diff --git a/src/main/java/org/opensearch/security/user/UserFilterType.java b/src/main/java/org/opensearch/security/user/UserFilterType.java new file mode 100644 index 0000000000..e13622e246 --- /dev/null +++ b/src/main/java/org/opensearch/security/user/UserFilterType.java @@ -0,0 +1,41 @@ +/* + * 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.user; + +/** + * Filter types to be used when requesting the list of users. + * 'Service' refers to accounts used by other services like Dashboards + * 'Internal' refers the standard user accounts + * 'Any' refers to both types of accounts + */ +public enum UserFilterType { + + ANY("any"), + INTERNAL("internal"), + SERVICE("service"); + + private String name; + + UserFilterType(String name) { + this.name = name; + } + + public static UserFilterType fromString(String name) { + for (UserFilterType b : UserFilterType.values()) { + if (b.name.equalsIgnoreCase(name)) { + return b; + } + } + return UserFilterType.ANY; + } + +} diff --git a/src/main/java/org/opensearch/security/user/UserService.java b/src/main/java/org/opensearch/security/user/UserService.java index 95241070ae..d6348778ed 100644 --- a/src/main/java/org/opensearch/security/user/UserService.java +++ b/src/main/java/org/opensearch/security/user/UserService.java @@ -13,10 +13,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.stream.Collectors; @@ -39,12 +41,15 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentHelper; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.BasicAuthToken; import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.Hashed; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.InternalUserV7; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.SecurityJsonNode; import org.passay.CharacterRule; @@ -242,7 +247,7 @@ public static String generatePassword() { * @param accountName A string representing the name of the account * @return A string auth token */ - public String generateAuthToken(String accountName) throws IOException { + public AuthToken generateAuthToken(String accountName) throws IOException { final SecurityDynamicConfiguration internalUsersConfiguration = load(getUserConfigName(), false); @@ -283,7 +288,7 @@ public String generateAuthToken(String accountName) throws IOException { saveAndUpdateConfigs(getUserConfigName().toString(), client, CType.INTERNALUSERS, internalUsersConfiguration); authToken = Base64.getUrlEncoder().encodeToString((accountName + ":" + plainTextPassword).getBytes(StandardCharsets.UTF_8)); - return authToken; + return new BasicAuthToken(authToken); } catch (JsonProcessingException ex) { throw new UserServiceException(FAILED_ACCOUNT_RETRIEVAL_MESSAGE); @@ -315,4 +320,45 @@ public static void saveAndUpdateConfigs( throw ExceptionsHelper.convertToOpenSearchException(e); } } + + /** + * Removes accounts that are not of the requested type from the SecurityDynamicConfiguration object passed. + * + * Accounts with the 'service' attribute set to true, are considered of type 'service'. + * Accounts with the 'service' attribute set to false or without the 'service' attribute, are considered of type 'internal'. + * + * @param configuration SecurityDynamicConfiguration object containing all accounts + * @param requestedAccountType The type of account to be kept. Should be "service" or "internal" + * + */ + public void includeAccountsIfType(SecurityDynamicConfiguration configuration, UserFilterType requestedAccountType) { + if (requestedAccountType != UserFilterType.INTERNAL && requestedAccountType != UserFilterType.SERVICE) { + return; + } + List toBeRemoved = new ArrayList<>(); + + if (requestedAccountType == UserFilterType.SERVICE) { + accountsToRemoveFromConfiguration(configuration, toBeRemoved, false); + } else if (requestedAccountType == UserFilterType.INTERNAL) { + accountsToRemoveFromConfiguration(configuration, toBeRemoved, true); + } + configuration.remove(toBeRemoved); + } + + private void accountsToRemoveFromConfiguration( + SecurityDynamicConfiguration configuration, + List toBeRemoved, + boolean isServiceAccountRequested + ) { + for (Map.Entry entry : configuration.getCEntries().entrySet()) { + final InternalUserV7 internalUserEntry = (InternalUserV7) entry.getValue(); + final Map accountAttributes = internalUserEntry.getAttributes(); + final String accountName = entry.getKey(); + final boolean isServiceAccount = Boolean.parseBoolean(accountAttributes.getOrDefault("service", "false").toString()); + + if (isServiceAccount == isServiceAccountRequested) { + toBeRemoved.add(accountName); + } + } + } } diff --git a/src/test/java/org/opensearch/security/IntegrationTests.java b/src/test/java/org/opensearch/security/IntegrationTests.java index 303e4aabff..c407b3de97 100644 --- a/src/test/java/org/opensearch/security/IntegrationTests.java +++ b/src/test/java/org/opensearch/security/IntegrationTests.java @@ -331,7 +331,7 @@ public void testSpecialUsernames() throws Exception { setup(); RestHelper rh = nonSslRestHelper(); - Assert.assertEquals(HttpStatus.SC_OK, rh.executeGetRequest("", encodeBasicHeader("bug.99", "nagilum")).getStatusCode()); + Assert.assertEquals(HttpStatus.SC_OK, rh.executeGetRequest("", encodeBasicHeader("bug.88", "nagilum")).getStatusCode()); Assert.assertEquals(HttpStatus.SC_UNAUTHORIZED, rh.executeGetRequest("", encodeBasicHeader("a", "b")).getStatusCode()); Assert.assertEquals( HttpStatus.SC_OK, diff --git a/src/test/java/org/opensearch/security/UserServiceUnitTests.java b/src/test/java/org/opensearch/security/UserServiceUnitTests.java new file mode 100644 index 0000000000..6bdef8d167 --- /dev/null +++ b/src/test/java/org/opensearch/security/UserServiceUnitTests.java @@ -0,0 +1,100 @@ +/* + * 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; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.opensearch.client.Client; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.user.UserFilterType; +import org.opensearch.security.user.UserService; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +public class UserServiceUnitTests { + SecurityDynamicConfiguration config; + @Mock + ClusterService clusterService; + @Mock + ConfigurationRepository configurationRepository; + @Mock + Client client; + UserService userService; + + final int SERVICE_ACCOUNTS_IN_SETTINGS = 1; + final int INTERNAL_ACCOUNTS_IN_SETTINGS = 67; + String serviceAccountUsername = "bug.99"; + String internalAccountUsername = "sarek"; + + @Before + public void setup() throws Exception { + String usersYmlFile = "./internal_users.yml"; + Settings.Builder builder = Settings.builder(); + userService = new UserService(clusterService, configurationRepository, builder.build(), client); + config = readConfigFromYml(usersYmlFile, CType.INTERNALUSERS); + } + + @Test + public void testServiceUserTypeFilter() { + + userService.includeAccountsIfType(config, UserFilterType.SERVICE); + Assert.assertEquals(SERVICE_ACCOUNTS_IN_SETTINGS, config.getCEntries().size()); + Assert.assertEquals(config.getCEntries().containsKey(serviceAccountUsername), true); + Assert.assertEquals(config.getCEntries().containsKey(internalAccountUsername), false); + + } + + @Test + public void testInternalUserTypeFilter() { + userService.includeAccountsIfType(config, UserFilterType.INTERNAL); + Assert.assertEquals(INTERNAL_ACCOUNTS_IN_SETTINGS, config.getCEntries().size()); + Assert.assertEquals(config.getCEntries().containsKey(serviceAccountUsername), false); + Assert.assertEquals(config.getCEntries().containsKey(internalAccountUsername), true); + + } + + @Test + public void testAnyUserTypeFilter() { + userService.includeAccountsIfType(config, UserFilterType.ANY); + Assert.assertEquals(INTERNAL_ACCOUNTS_IN_SETTINGS + SERVICE_ACCOUNTS_IN_SETTINGS, config.getCEntries().size()); + Assert.assertEquals(config.getCEntries().containsKey(serviceAccountUsername), true); + Assert.assertEquals(config.getCEntries().containsKey(internalAccountUsername), true); + } + + private SecurityDynamicConfiguration readConfigFromYml(String file, CType cType) throws Exception { + final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + final String TEST_RESOURCE_RELATIVE_PATH = "../../resources/test/"; + + final String adjustedFilePath = TEST_RESOURCE_RELATIVE_PATH + file; + JsonNode jsonNode = YAML.readTree(Files.readString(new File(adjustedFilePath).toPath(), StandardCharsets.UTF_8)); + int configVersion = 1; + + if (jsonNode.get("_meta") != null) { + Assert.assertEquals(jsonNode.get("_meta").get("type").asText(), cType.toLCString()); + configVersion = jsonNode.get("_meta").get("config_version").asInt(); + } + return SecurityDynamicConfiguration.fromNode(jsonNode, cType, configVersion, 0, 0); + } + +} 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 e271c7b838..9c51dd714b 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -40,6 +40,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; @@ -102,9 +103,9 @@ public void testCreateJwtWithRoles() throws Exception { Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); - SignedJWT signedJWT = SignedJWT.parse(encodedJwt); + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); @@ -119,14 +120,14 @@ public void testCreateJwtWithRoles() throws Exception { } @Test - public void testCreateJwtWithRoleSecurityMode() throws Exception { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("IT", "HR"); - List backendRoles = List.of("Sales", "Support"); - String expectedRoles = "IT,HR"; - String expectedBackendRoles = "Sales,Support"; + public void testCreateJwtWithBackendRolesIncluded() throws Exception { + final String issuer = "cluster_0"; + final String subject = "admin"; + final String audience = "audience_0"; + final List roles = List.of("IT", "HR"); + final List backendRoles = List.of("Sales", "Support"); + final String expectedRoles = "IT,HR"; + final String expectedBackendRoles = "Sales,Support"; int expirySeconds = 300; LongSupplier currentTime = () -> (long) 100; @@ -139,9 +140,9 @@ public void testCreateJwtWithRoleSecurityMode() throws Exception { // CS-ENFORCE-SINGLE .build(); final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - SignedJWT signedJWT = SignedJWT.parse(encodedJwt); + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); @@ -166,10 +167,10 @@ public void testCreateJwtWithNegativeExpiry() { Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - Throwable exception = assertThrows(RuntimeException.class, () -> { + final Throwable exception = assertThrows(RuntimeException.class, () -> { try { jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (Exception e) { + } catch (final Exception e) { throw new RuntimeException(e); } }); @@ -183,39 +184,32 @@ public void testCreateJwtWithExceededExpiry() throws Exception { String audience = "audience_0"; List roles = List.of("IT", "HR"); List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 900; + int expirySeconds = 900_000; LongSupplier currentTime = () -> (long) 100; String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - assertEquals( - "java.lang.IllegalArgumentException: The provided expiration time exceeds the maximum allowed duration of 600 seconds", - exception.getMessage() - ); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. + assertThat(authToken.getExpiresInSeconds(), not(equalTo(expirySeconds))); + assertThat(authToken.getExpiresInSeconds(), equalTo(600L)); } @Test public void testCreateJwtWithBadEncryptionKey() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("admin"); - Integer expirySeconds = 300; + final String issuer = "cluster_0"; + final String subject = "admin"; + final String audience = "audience_0"; + final List roles = List.of("admin"); + final Integer expirySeconds = 300; Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - Throwable exception = assertThrows(RuntimeException.class, () -> { + final Throwable exception = assertThrows(RuntimeException.class, () -> { try { new JwtVendor(settings, Optional.empty()).createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (Exception e) { + } catch (final Exception e) { throw new RuntimeException(e); } }); @@ -233,10 +227,10 @@ public void testCreateJwtWithBadRoles() { Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - Throwable exception = assertThrows(RuntimeException.class, () -> { + final Throwable exception = assertThrows(RuntimeException.class, () -> { try { jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (Exception e) { + } catch (final Exception e) { throw new RuntimeException(e); } }); @@ -249,7 +243,7 @@ public void testCreateJwtLogsCorrectly() throws Exception { logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); when(mockAppender.getName()).thenReturn("MockAppender"); when(mockAppender.isStarted()).thenReturn(true); - Logger logger = (Logger) LogManager.getLogger(JwtVendor.class); + final Logger logger = (Logger) LogManager.getLogger(JwtVendor.class); logger.addAppender(mockAppender); logger.setLevel(Level.DEBUG); @@ -258,24 +252,24 @@ public void testCreateJwtLogsCorrectly() throws Exception { String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("IT", "HR"); - List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 300; + final String issuer = "cluster_0"; + final String subject = "admin"; + final String audience = "audience_0"; + final List roles = List.of("IT", "HR"); + final List backendRoles = List.of("Sales", "Support"); + final int expirySeconds = 300; - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); verify(mockAppender, times(1)).append(logEventCaptor.capture()); - LogEvent logEvent = logEventCaptor.getValue(); - String logMessage = logEvent.getMessage().getFormattedMessage(); + final LogEvent logEvent = logEventCaptor.getValue(); + final String logMessage = logEvent.getMessage().getFormattedMessage(); assertTrue(logMessage.startsWith("Created JWT:")); - String[] parts = logMessage.split("\\."); + final String[] parts = logMessage.split("\\."); assertTrue(parts.length >= 3); } } diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/UserApiTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/UserApiTest.java index b331743816..6e6cb029b2 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/api/UserApiTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/api/UserApiTest.java @@ -19,14 +19,17 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; +import com.fasterxml.jackson.databind.JsonNode; import org.apache.http.Header; import org.apache.http.HttpStatus; import org.apache.http.message.BasicHeader; +import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Test; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.dlic.rest.validation.PasswordValidator; import org.opensearch.security.dlic.rest.validation.RequestContentValidator; import org.opensearch.security.securityconf.impl.CType; @@ -59,7 +62,7 @@ protected String getEndpointPrefix() { private static final String ENABLED_SERVICE_ACCOUNT_BODY = "{" + " \"attributes\": { \"service\": \"true\", " - + "\"enabled\": \"true\"}" + + " \"enabled \": \"true\"}" + " }\n"; private static final String DISABLED_SERVICE_ACCOUNT_BODY = "{" @@ -166,6 +169,55 @@ private HttpResponse from(Future future) { } } + @Test + public void testUserFilters() throws Exception { + setup(); + rh.keystore = "restapi/kirk-keystore.jks"; + rh.sendAdminCertificate = true; + final int SERVICE_ACCOUNTS_IN_SETTINGS = 1; + final int INTERNAL_ACCOUNTS_IN_SETTINGS = 20; + final String serviceAccountName = "JohnDoeService"; + HttpResponse response; + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?filterBy=internal"); + + Assert.assertEquals(response.getBody(), HttpStatus.SC_OK, response.getStatusCode()); + JsonNode list = DefaultObjectMapper.readTree(response.getBody()); + Assert.assertEquals(INTERNAL_ACCOUNTS_IN_SETTINGS, list.size()); + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?filterBy=service"); + Assert.assertEquals(response.getBody(), HttpStatus.SC_OK, response.getStatusCode()); + list = DefaultObjectMapper.readTree(response.getBody()); + assertThat(list, Matchers.emptyIterable()); + + response = rh.executePutRequest(ENDPOINT + "/internalusers/" + serviceAccountName, ENABLED_SERVICE_ACCOUNT_BODY); + + // repeat assertions after adding the service account + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?filterBy=internal"); + + Assert.assertEquals(response.getBody(), HttpStatus.SC_OK, response.getStatusCode()); + list = DefaultObjectMapper.readTree(response.getBody()); + Assert.assertEquals(INTERNAL_ACCOUNTS_IN_SETTINGS, list.size()); + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?filterBy=service"); + Assert.assertEquals(response.getBody(), HttpStatus.SC_OK, response.getStatusCode()); + list = DefaultObjectMapper.readTree(response.getBody()); + Assert.assertEquals(SERVICE_ACCOUNTS_IN_SETTINGS, list.size()); + assertThat(response.findValueInJson(serviceAccountName + ".attributes.service"), containsString("true")); + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?filterBy=ssas"); + Assert.assertEquals(response.getBody(), HttpStatus.SC_OK, response.getStatusCode()); + list = DefaultObjectMapper.readTree(response.getBody()); + Assert.assertEquals(SERVICE_ACCOUNTS_IN_SETTINGS + INTERNAL_ACCOUNTS_IN_SETTINGS, list.size()); + + response = rh.executeGetRequest(ENDPOINT + "/internalusers?wrongparameter=jhondoe"); + Assert.assertEquals(response.getBody(), HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); + + response = rh.executePutRequest(ENDPOINT + "/internalusers", "{sample:value"); + Assert.assertEquals(response.getBody(), HttpStatus.SC_METHOD_NOT_ALLOWED, response.getStatusCode()); + } + @Test public void testUserApi() throws Exception { diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java index 2e099e113e..751cec1aec 100644 --- a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -107,7 +107,7 @@ public void testNoKey() { false ) ); - assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + assertThat(exception.getMessage(), equalTo("Unable to find on behalf of authenticator signing_key")); } @Test @@ -115,13 +115,16 @@ public void testEmptyKey() { Exception exception = assertThrows( RuntimeException.class, () -> extractCredentialsFromJwtHeader( - null, + "", claimsEncryptionKey, Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), false ) ); - assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + assertThat( + exception.getMessage(), + equalTo("Signing key size was 0 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } @Test @@ -135,7 +138,10 @@ public void testBadKey() { false ) ); - assertTrue(exception.getMessage().contains("The specified key byte array is 80 bits")); + assertThat( + exception.getMessage(), + equalTo("Signing key size was 128 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } @Test @@ -145,7 +151,10 @@ public void testWeakKeyExceptionHandling() throws Exception { OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, "testCluster"); fail("Expected WeakKeyException"); } catch (OpenSearchSecurityException e) { - assertTrue("Expected error message to contain WeakKeyException", e.getMessage().contains("WeakKeyException")); + assertThat( + e.getMessage(), + equalTo("Signing key size was 56 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java new file mode 100644 index 0000000000..bc3f3f9732 --- /dev/null +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -0,0 +1,247 @@ +/* + * 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.identity; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.identity.Subject; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.OnBehalfOfClaims; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.securityconf.ConfigModel; +import org.opensearch.security.securityconf.DynamicConfigModel; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.security.user.UserService; +import org.opensearch.threadpool.ThreadPool; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class SecurityTokenManagerTest { + + private SecurityTokenManager tokenManager; + + @Mock + private JwtVendor jwtVendor; + @Mock + private ClusterService cs; + @Mock + private ThreadPool threadPool; + @Mock + private UserService userService; + + @Before + public void setup() { + tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService)); + } + + @After + public void after() { + verifyNoMoreInteractions(cs); + verifyNoMoreInteractions(threadPool); + verifyNoMoreInteractions(userService); + } + + public void onConfigModelChanged_oboNotSupported() { + final ConfigModel configModel = mock(ConfigModel.class); + + tokenManager.onConfigModelChanged(configModel); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); + verifyNoMoreInteractions(configModel); + } + + @Test + public void onDynamicConfigModelChanged_JwtVendorEnabled() { + final ConfigModel configModel = mock(ConfigModel.class); + final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(); + + tokenManager.onConfigModelChanged(configModel); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(true)); + verify(mockConfigModel).getDynamicOnBehalfOfSettings(); + verifyNoMoreInteractions(configModel); + } + + @Test + public void onDynamicConfigModelChanged_JwtVendorDisabled() { + final Settings settings = Settings.builder().put("enabled", false).build(); + final DynamicConfigModel dcm = mock(DynamicConfigModel.class); + when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + tokenManager.onDynamicConfigModelChanged(dcm); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); + verify(dcm).getDynamicOnBehalfOfSettings(); + verify(tokenManager, never()).createJwtVendor(any()); + } + + /** Creates the jwt vendor and returns a mock for validation if needed */ + private DynamicConfigModel createMockJwtVendorInTokenManager() { + final Settings settings = Settings.builder().put("enabled", true).build(); + final DynamicConfigModel dcm = mock(DynamicConfigModel.class); + when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); + tokenManager.onDynamicConfigModelChanged(dcm); + return dcm; + } + + @Test + public void issueServiceAccountToken_error() throws Exception { + final String expectedAccountName = "abc-123"; + when(userService.generateAuthToken(expectedAccountName)).thenThrow(new IOException("foobar")); + + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueServiceAccountToken(expectedAccountName) + ); + assertThat(exception.getMessage(), equalTo("Unable to issue service account token")); + + verify(userService).generateAuthToken(expectedAccountName); + } + + @Test + public void issueServiceAccountToken_success() throws Exception { + final String expectedAccountName = "abc-123"; + final AuthToken authToken = mock(AuthToken.class); + when(userService.generateAuthToken(expectedAccountName)).thenReturn(authToken); + + final AuthToken token = tokenManager.issueServiceAccountToken(expectedAccountName); + + assertThat(token, equalTo(authToken)); + + verify(userService).generateAuthToken(expectedAccountName); + } + + @Test + public void issueOnBehalfOfToken_notEnabledOnCluster() { + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, null) + ); + assertThat( + exception.getMessage(), + equalTo("The OnBehalfOf token generation is not enabled, see {link to doc} for more information on this feature.") + ); + } + + @Test + public void issueOnBehalfOfToken_unsupportedSubjectType() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> tokenManager.issueOnBehalfOfToken(mock(Subject.class), null) + ); + assertThat(exception.getMessage(), equalTo("Unsupported subject to generate OnBehalfOfToken")); + } + + @Test + public void issueOnBehalfOfToken_missingAudience() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims(null, 450L)) + ); + assertThat(exception.getMessage(), equalTo("Claims must be supplied with an audience value")); + } + + @Test + public void issueOnBehalfOfToken_cannotFindUserInThreadContext() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) + ); + assertThat(exception.getMessage(), equalTo("Unsupported user to generate OnBehalfOfToken")); + + verify(threadPool).getThreadContext(); + } + + @Test + public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(); + + when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenThrow( + new RuntimeException("foobar") + ); + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) + ); + assertThat(exception.getMessage(), equalTo("Unable to generate OnBehalfOfToken")); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void issueOnBehalfOfToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } +} diff --git a/src/test/resources/internal_users.yml b/src/test/resources/internal_users.yml index 62bf83ae58..a5eeb6fddb 100644 --- a/src/test/resources/internal_users.yml +++ b/src/test/resources/internal_users.yml @@ -2,13 +2,20 @@ _meta: type: "internalusers" config_version: 2 -bug.99: +bug.88: hash: "$2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m" reserved: false hidden: false backend_roles: [] attributes: {} description: "Migrated from v6" +bug.99: + hash: "$2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m" + reserved: false + hidden: false + backend_roles: [] + attributes: {service : "true"} + description: "Migrated from v6" user_c: hash: "$2a$04$jQcEXpODnTFoGDuA7DPdSevA84CuH/7MOYkb80M3XZIrH76YMWS9G" reserved: false