diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index 1f60cf92d5..01f7ad9f3d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -38,15 +38,19 @@ import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.security.privileges.ActionPrivilegesTest.IndexPrivileges.IndicesAndAliases.resolved; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isAllowed; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isPartiallyOk; @@ -280,6 +284,70 @@ public void hasAny_wildcard() throws Exception { isForbidden(missingPrivileges("cluster:whatever")) ); } + + @Test + public void apiToken_explicit_failsWithWildcard() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("*"), List.of()) + ); + // Explicit fails + assertThat( + subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), + isForbidden(missingPrivileges("cluster:whatever")) + ); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithExactMatch() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("cluster:whatever"), List.of()) + ); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever", "cluster:other")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + + SecurityDynamicConfiguration config = SecurityDynamicConfiguration.fromYaml( + "CLUSTER_ALL:\n allowed_actions:\n - \"cluster:*\"", + CType.ACTIONGROUPS + ); + + FlattenedActionGroups actionGroups = new FlattenedActionGroups(config); + ActionPrivileges subject = new ActionPrivileges(roles, actionGroups, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("CLUSTER_ALL"), List.of()) + ); + + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:monitor/main"), isAllowed()); + } } /** @@ -314,9 +382,20 @@ public void positive_full() throws Exception { assertThat(result, isAllowed()); } + @Test + public void apiTokens_positive_full() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isAllowed()); + } + @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11", "index_a12")); if (covers(ctx, "index_a11", "index_a12")) { @@ -330,7 +409,7 @@ public void positive_partial() throws Exception { @Test public void positive_partial2() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -363,14 +442,26 @@ public void positive_noLocal() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } + @Test + public void apiToken_negative_noPermissions() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of(), List.of(new ApiToken.IndexPermission(List.of(), List.of()))) + ); + + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isForbidden()); + } + @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -382,7 +473,7 @@ public void negative_wrongAction() throws Exception { @Test public void positive_hasExplicit_full() { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(ctx, requiredActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -397,7 +488,21 @@ public void positive_hasExplicit_full() { } } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + @Test + public void apiTokens_positive_hasExplicit_full() { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + + PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(context, requiredActions, resolved("index_a11")); + + assertThat(result, isForbidden()); + + } + + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -522,7 +627,7 @@ public static class DataStreams { @Test public void positive_full() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); if (covers(ctx, "data_stream_a11")) { assertThat(result, isAllowed()); @@ -538,7 +643,7 @@ public void positive_full() throws Exception { @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -569,19 +674,19 @@ public void positive_partial() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(otherActions))); } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -1039,10 +1144,14 @@ static SecurityDynamicConfiguration createRoles(int numberOfRoles, int n } } - static PrivilegesEvaluationContext ctx(String... roles) { - User user = new User("test_user"); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return ctxWithUserName("test-user", roles); + } + + static RoleBasedPrivilegesEvaluationContext ctxWithUserName(String userName, String... roles) { + User user = new User(userName); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, @@ -1054,10 +1163,25 @@ static PrivilegesEvaluationContext ctx(String... roles) { ); } - static PrivilegesEvaluationContext ctxByUsername(String username) { + static PermissionBasedPrivilegesEvaluationContext ctxForApiToken(String userName, Permissions permissions) { + User user = new User(userName); + user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); + return new PermissionBasedPrivilegesEvaluationContext( + user, + null, + null, + null, + null, + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), + null, + permissions + ); + } + + static RoleBasedPrivilegesEvaluationContext ctxByUsername(String username) { User user = new User(username); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java index e098a605e5..246d28d542 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java @@ -231,14 +231,14 @@ public void equals() { assertFalse(a1.equals(a1.toString())); } - private static PrivilegesEvaluationContext ctx() { + private static RoleBasedPrivilegesEvaluationContext ctx() { IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); IndexResolverReplacer indexResolverReplacer = new IndexResolverReplacer(indexNameExpressionResolver, () -> CLUSTER_STATE, null); User user = new User("test_user"); user.addAttributes(ImmutableMap.of("attrs.a11", "a11")); user.addAttributes(ImmutableMap.of("attrs.year", "year")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), "indices:action/test", diff --git a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java index 1e61aa0206..6576a84fb4 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java @@ -188,13 +188,13 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { .collect(Collectors.toList()); for (final Endpoint endpoint : noSslEndpoints) { final String permission = ENDPOINTS_WITH_PERMISSIONS.get(endpoint).build(); - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); Assert.assertTrue(endpoint.name(), actionPrivileges.hasExplicitClusterPrivilege(ctx, permission).isAllowed()); assertHasNoPermissionsForRestApiAdminOnePermissionRole(endpoint, ctx); } // verify SSL endpoint with 2 actions for (final String sslAction : ImmutableSet.of(CERTS_INFO_ACTION, RELOAD_CERTS_ACTION)) { - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.SSL); Assert.assertTrue( Endpoint.SSL + "/" + sslAction, @@ -203,7 +203,7 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.SSL, ctx); } // verify CONFIG endpoint with 1 action - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.CONFIG); Assert.assertTrue( Endpoint.SSL + "/" + SECURITY_CONFIG_UPDATE, @@ -212,7 +212,10 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.CONFIG, ctx); } - void assertHasNoPermissionsForRestApiAdminOnePermissionRole(final Endpoint allowEndpoint, final PrivilegesEvaluationContext ctx) { + void assertHasNoPermissionsForRestApiAdminOnePermissionRole( + final Endpoint allowEndpoint, + final RoleBasedPrivilegesEvaluationContext ctx + ) { final Collection noPermissionEndpoints = ENDPOINTS_WITH_PERMISSIONS.keySet() .stream() .filter(e -> e != allowEndpoint) @@ -250,8 +253,17 @@ static SecurityDynamicConfiguration createRolesConfig() throws IOExcepti return SecurityDynamicConfiguration.fromNode(rolesNode, CType.ROLES, 2, 0, 0); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.copyOf(roles), null, null, null, null, null, null); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( + new User("test_user"), + ImmutableSet.copyOf(roles), + null, + null, + null, + null, + null, + null + ); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java index 2c8e6de587..65b2f30b3a 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java @@ -35,7 +35,7 @@ import org.opensearch.index.query.RangeQueryBuilder; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.search.internal.ShardSearchRequest; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.Base64Helper; @@ -55,6 +55,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; public class DlsFlsLegacyHeadersTest { static NamedXContentRegistry xContentRegistry = new NamedXContentRegistry( @@ -255,11 +256,11 @@ public void performHeaderDecoration_oldNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -277,7 +278,7 @@ public void performHeaderDecoration_actionRequest() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // SearchRequest does extend ActionRequest, thus the headers must not be set @@ -296,11 +297,11 @@ public void performHeaderDecoration_newNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_3_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -337,7 +338,7 @@ public void prepare_ccs() throws Exception { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - PrivilegesEvaluationContext ctx = new PrivilegesEvaluationContext( + RoleBasedPrivilegesEvaluationContext ctx = new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of("test_role"), null, @@ -352,11 +353,11 @@ public void prepare_ccs() throws Exception { assertTrue(threadContext.getResponseHeaders().containsKey(ConfigConstants.OPENDISTRO_SECURITY_DLS_QUERY_HEADER)); } - static PrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { + static RoleBasedPrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java index 97a0ddb69e..f81fe39d5d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java @@ -52,8 +52,8 @@ import org.opensearch.index.query.QueryBuilders; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -121,7 +121,7 @@ public static class IndicesAndAliases_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -518,7 +518,7 @@ public IndicesAndAliases_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -566,7 +566,7 @@ public static class IndicesAndAliases_isUnrestricted { final User user; final IndicesSpec indicesSpec; final IndexResolverReplacer.Resolved resolvedIndices; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -833,7 +833,7 @@ public IndicesRequest indices(String... strings) { return this; } }); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -874,7 +874,7 @@ public static class DataStreams_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -1118,7 +1118,7 @@ public DataStreams_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -1146,7 +1146,9 @@ public void invalidQuery() throws Exception { @Test(expected = PrivilegesEvaluationException.class) public void invalidTemplatedQuery() throws Exception { DocumentPrivileges.DlsQuery.create("{\"invalid\": \"totally ${attr.foo}\"}", xContentRegistry) - .evaluate(new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null)); + .evaluate( + new RoleBasedPrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null) + ); } @Test diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java index a7ce8b0c1d..b5b7cf582a 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java @@ -23,7 +23,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -115,8 +115,8 @@ static FieldMasking createSubject(SecurityDynamicConfiguration roleConfi ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java index 54a32e9972..731c910fc8 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java @@ -22,7 +22,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -149,8 +149,8 @@ static FieldPrivileges createSubject(SecurityDynamicConfiguration roleCo ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 0802cb856c..db67791c1c 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -131,6 +131,10 @@ import org.opensearch.search.internal.ReaderContext; import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; +import org.opensearch.security.action.apitokens.ApiTokenAction; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.action.apitokens.ApiTokenUpdateAction; +import org.opensearch.security.action.apitokens.TransportApiTokenUpdateAction; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -257,6 +261,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; + private volatile ApiTokenRepository apiTokenRepository; private volatile AdminDNs adminDns; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); @@ -646,6 +651,21 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); + handlers.add( + new ApiTokenAction( + Objects.requireNonNull(threadPool), + cr, + evaluator, + settings, + adminDns, + auditLog, + configPath, + principalExtractor, + apiTokenRepository, + cs, + indexNameExpressionResolver + ) + ); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -687,6 +707,7 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre List> actions = new ArrayList<>(1); if (!disabled && !SSLConfig.isSslOnlyMode()) { actions.add(new ActionHandler<>(ConfigUpdateAction.INSTANCE, TransportConfigUpdateAction.class)); + actions.add(new ActionHandler<>(ApiTokenUpdateAction.INSTANCE, TransportApiTokenUpdateAction.class)); // external storage does not support reload and does not provide SSL certs info if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); @@ -719,6 +740,7 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1105,6 +1127,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); tokenManager = new SecurityTokenManager(cs, threadPool, userService); + apiTokenRepository = new ApiTokenRepository(localClient, clusterService, tokenManager); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1120,7 +1143,8 @@ public Collection createComponents( privilegesInterceptor, cih, irr, - namedXContentRegistry.get() + namedXContentRegistry.get(), + apiTokenRepository ); dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); @@ -1162,7 +1186,7 @@ public Collection createComponents( configPath, compatConfig ); - dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher); + dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher, apiTokenRepository); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); @@ -1212,6 +1236,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(apiTokenRepository); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -2142,7 +2167,11 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); - return Collections.singletonList(systemIndexDescriptor); + final SystemIndexDescriptor apiTokenSystemIndexDescriptor = new SystemIndexDescriptor( + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, + "Security API token index" + ); + return List.of(systemIndexDescriptor, apiTokenSystemIndexDescriptor); } @Override diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java new file mode 100644 index 0000000000..b790f0d38f --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -0,0 +1,238 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +public class ApiToken implements ToXContent { + public static final String NAME_FIELD = "name"; + public static final String ISSUED_AT_FIELD = "iat"; + public static final String CLUSTER_PERMISSIONS_FIELD = "cluster_permissions"; + public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; + public static final String INDEX_PATTERN_FIELD = "index_pattern"; + public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions"; + public static final String EXPIRATION_FIELD = "expiration"; + + private final String name; + private final Instant creationTime; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; + + public ApiToken(String name, List clusterPermissions, List indexPermissions, Long expiration) { + this.creationTime = Instant.now(); + this.name = name; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.expiration = expiration; + } + + public ApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Instant creationTime, + Long expiration + ) { + this.name = name; + this.clusterPermissions = clusterPermissions; + this.indexPermissions = indexPermissions; + this.creationTime = creationTime; + this.expiration = expiration; + } + + public static class IndexPermission implements ToXContent { + private final List indexPatterns; + private final List allowedActions; + + public IndexPermission(List indexPatterns, List allowedActions) { + this.indexPatterns = indexPatterns; + this.allowedActions = allowedActions; + } + + public List getAllowedActions() { + return allowedActions; + } + + public List getIndexPatterns() { + return indexPatterns; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.array(INDEX_PATTERN_FIELD, indexPatterns.toArray(new String[0])); + builder.array(ALLOWED_ACTIONS_FIELD, allowedActions.toArray(new String[0])); + builder.endObject(); + return builder; + } + + public static IndexPermission fromXContent(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + + return new IndexPermission(indexPatterns, allowedActions); + } + + } + + /** + * Class represents an API token. + * Expected class structure + * { + * name: "token_name", + * jti: "encrypted_token", + * creation_time: 1234567890, + * cluster_permissions: ["cluster_permission1", "cluster_permission2"], + * index_permissions: [ + * { + * index_pattern: ["index_pattern1", "index_pattern2"], + * allowed_actions: ["allowed_action1", "allowed_action2"] + * } + * ], + * expiration: 1234567890 + * } + */ + public static ApiToken fromXContent(XContentParser parser) throws IOException { + String name = null; + List clusterPermissions = new ArrayList<>(); + List indexPermissions = new ArrayList<>(); + Instant creationTime = null; + long expiration = 0; + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + switch (currentFieldName) { + case NAME_FIELD: + name = parser.text(); + break; + case ISSUED_AT_FIELD: + creationTime = Instant.ofEpochMilli(parser.longValue()); + break; + case EXPIRATION_FIELD: + expiration = parser.longValue(); + break; + } + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case CLUSTER_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + clusterPermissions.add(parser.text()); + } + break; + case INDEX_PERMISSIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + indexPermissions.add(parseIndexPermission(parser)); + } + } + break; + } + } + } + + return new ApiToken(name, clusterPermissions, indexPermissions, creationTime, expiration); + } + + private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + return new IndexPermission(indexPatterns, allowedActions); + } + + public String getName() { + return name; + } + + public Long getExpiration() { + return expiration; + } + + public Instant getCreationTime() { + return creationTime; + } + + public List getClusterPermissions() { + return clusterPermissions; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field(NAME_FIELD, name); + xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); + xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); + xContentBuilder.field(ISSUED_AT_FIELD, creationTime.toEpochMilli()); + xContentBuilder.endObject(); + return xContentBuilder; + } + + public List getIndexPermissions() { + return indexPermissions; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java new file mode 100644 index 0000000000..448c5fecc1 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -0,0 +1,385 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.dlic.rest.api.Endpoint; +import org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; +import org.opensearch.security.dlic.rest.support.Utils; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.ssl.transport.PrincipalExtractor; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.ISSUED_AT_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; + +public class ApiTokenAction extends BaseRestHandler { + private final ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); + private final ThreadPool threadPool; + private final ConfigurationRepository configurationRepository; + private final PrivilegesEvaluator privilegesEvaluator; + private final SecurityApiDependencies securityApiDependencies; + private final ClusterService clusterService; + private final IndexNameExpressionResolver indexNameExpressionResolver; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) + ); + + public ApiTokenAction( + ThreadPool threadpool, + ConfigurationRepository configurationRepository, + PrivilegesEvaluator privilegesEvaluator, + Settings settings, + AdminDNs adminDns, + AuditLog auditLog, + Path configPath, + PrincipalExtractor principalExtractor, + ApiTokenRepository apiTokenRepository, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver + ) { + this.apiTokenRepository = apiTokenRepository; + this.threadPool = threadpool; + this.configurationRepository = configurationRepository; + this.privilegesEvaluator = privilegesEvaluator; + this.securityApiDependencies = new SecurityApiDependencies( + adminDns, + configurationRepository, + privilegesEvaluator, + new RestApiPrivilegesEvaluator(settings, adminDns, privilegesEvaluator, principalExtractor, configPath, threadPool), + new RestApiAdminPrivilegesEvaluator( + threadPool.getThreadContext(), + privilegesEvaluator, + adminDns, + settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) + ), + auditLog, + settings + ); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; + } + + @Override + public String getName() { + return "api_token_action"; + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { + authorizeSecurityAccess(request); + return doPrepareRequest(request, client); + } + + RestChannelConsumer doPrepareRequest(RestRequest request, NodeClient client) { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + return switch (request.method()) { + case POST -> handlePost(request, client); + case DELETE -> handleDelete(request, client); + case GET -> handleGet(request, client); + default -> throw new IllegalArgumentException(request.method() + " not supported"); + }; + } + } + + private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { + return channel -> { + apiTokenRepository.getApiTokens(ActionListener.wrap(tokens -> { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startArray(); + for (ApiToken token : tokens.values()) { + builder.startObject(); + builder.field(NAME_FIELD, token.getName()); + builder.field(ISSUED_AT_FIELD, token.getCreationTime().toEpochMilli()); + builder.field(EXPIRATION_FIELD, token.getExpiration()); + builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); + builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); + builder.endObject(); + } + builder.endArray(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + builder.close(); + channel.sendResponse(response); + } catch (final Exception exception) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + } + }, exception -> { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + + })); + + }; + } + + private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { + return channel -> { + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + validateRequestParameters(requestBody); + + List clusterPermissions = extractClusterPermissions(requestBody); + List indexPermissions = extractIndexPermissions(requestBody); + String name = (String) requestBody.get(NAME_FIELD); + long expiration = (Long) requestBody.getOrDefault( + EXPIRATION_FIELD, + Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30) + ); + + // First check token count + apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> { + if (tokenCount >= 100) { + sendErrorResponse( + channel, + RestStatus.TOO_MANY_REQUESTS, + "Maximum limit of 100 API tokens reached. Please delete existing tokens before creating new ones." + ); + return; + } + + apiTokenRepository.createApiToken( + name, + clusterPermissions, + indexPermissions, + expiration, + wrapWithCacheRefresh(ActionListener.wrap(token -> { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("token", token); + builder.endObject(); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + builder.close(); + + }, + createException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to create token: " + createException.getMessage() + ) + ), client) + ); + }, + countException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to get token count: " + countException.getMessage() + ) + )); + + } catch (Exception e) { + sendErrorResponse(channel, RestStatus.BAD_REQUEST, "Invalid request: " + e.getMessage()); + } + }; + } + + private ActionListener wrapWithCacheRefresh(ActionListener listener, NodeClient client) { + return ActionListener.wrap(response -> { + try { + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute( + ApiTokenUpdateAction.INSTANCE, + updateRequest, + ActionListener.wrap( + updateResponse -> listener.onResponse(response), + exception -> listener.onFailure(new ApiTokenException("Failed to refresh cache", exception)) + ) + ); + } catch (Exception e) { + listener.onFailure(new ApiTokenException("Failed to refresh cache after operation", e)); + } + }, listener::onFailure); + } + + /** + * Extracts cluster permissions from the request body + */ + List extractClusterPermissions(Map requestBody) { + return safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); + } + + /** + * Extracts and builds index permissions from the request body + */ + List extractIndexPermissions(Map requestBody) { + List> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList()); + } + + /** + * Creates a single index permission from a permission map + */ + ApiToken.IndexPermission createIndexPermission(Map indexPerm) { + List indexPatterns; + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (indexPatternObj instanceof String) { + indexPatterns = Collections.singletonList((String) indexPatternObj); + } else { + indexPatterns = safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); + } + + List allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); + + return new ApiToken.IndexPermission(indexPatterns, allowedActions); + } + + /** + * Validates the request parameters + */ + void validateRequestParameters(Map requestBody) { + if (!requestBody.containsKey(NAME_FIELD)) { + throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD); + } + + if (requestBody.containsKey(EXPIRATION_FIELD)) { + Object expiration = requestBody.get(EXPIRATION_FIELD); + if (!(expiration instanceof Long)) { + throw new IllegalArgumentException(EXPIRATION_FIELD + " must be a long"); + } + } + + if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) { + Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD); + if (!(permissions instanceof List)) { + throw new IllegalArgumentException(CLUSTER_PERMISSIONS_FIELD + " must be an array"); + } + } + + if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) { + List> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + validateIndexPermissionsList(indexPermsList); + } + } + + /** + * Validates the index permissions list structure + */ + void validateIndexPermissionsList(List> indexPermsList) { + for (Map indexPerm : indexPermsList) { + if (!indexPerm.containsKey(INDEX_PATTERN_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + INDEX_PATTERN_FIELD); + } + if (!indexPerm.containsKey(ALLOWED_ACTIONS_FIELD)) { + throw new IllegalArgumentException("Each index permission must contain " + ALLOWED_ACTIONS_FIELD); + } + + Object indexPatternObj = indexPerm.get(INDEX_PATTERN_FIELD); + if (!(indexPatternObj instanceof String) && !(indexPatternObj instanceof List)) { + throw new IllegalArgumentException(INDEX_PATTERN_FIELD + " must be a string or array of strings"); + } + } + } + + private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { + return channel -> { + try { + final Map requestBody = request.contentOrSourceParamParser().map(); + + validateRequestParameters(requestBody); + apiTokenRepository.deleteApiToken( + (String) requestBody.get(NAME_FIELD), + wrapWithCacheRefresh(ActionListener.wrap(ignored -> { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "Token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + }, + deleteException -> sendErrorResponse( + channel, + RestStatus.INTERNAL_SERVER_ERROR, + "Failed to delete token: " + deleteException.getMessage() + ) + ), client) + ); + } catch (final Exception exception) { + RestStatus status = RestStatus.INTERNAL_SERVER_ERROR; + if (exception instanceof ApiTokenException) { + status = RestStatus.NOT_FOUND; + } + sendErrorResponse(channel, status, exception.getMessage()); + } + }; + } + + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + BytesRestResponse response = new BytesRestResponse(status, builder); + channel.sendResponse(response); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + + protected void authorizeSecurityAccess(RestRequest request) throws IOException { + // Check if user has security API access + if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) + || securityApiDependencies.restApiPrivilegesEvaluator().checkAccessPermissions(request, Endpoint.APITOKENS) == null)) { + throw new SecurityException("User does not have required security API access"); + } + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java new file mode 100644 index 0000000000..398da40e64 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenException.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import org.opensearch.OpenSearchException; + +public class ApiTokenException extends OpenSearchException { + public ApiTokenException(String message) { + super(message); + } + + public ApiTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java new file mode 100644 index 0000000000..d34368b34a --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.transport.client.Client; + +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; + +public class ApiTokenIndexHandler { + + private final Client client; + private final ClusterService clusterService; + private static final Logger LOGGER = LogManager.getLogger(ApiTokenIndexHandler.class); + + public ApiTokenIndexHandler(Client client, ClusterService clusterService) { + this.client = client; + this.clusterService = clusterService; + } + + public void indexTokenMetadata(ApiToken token, ActionListener listener) { + try { + XContentBuilder builder = XContentFactory.jsonBuilder(); + String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); + + IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); + + client.index(request, ActionListener.wrap(indexResponse -> { + LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + listener.onResponse(null); + }, exception -> { + LOGGER.error(exception.getMessage()); + LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + listener.onFailure(exception); + })); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void deleteToken(String name, ActionListener listener) { + DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( + QueryBuilders.matchQuery(NAME_FIELD, name) + ).setRefresh(true); + + client.execute(DeleteByQueryAction.INSTANCE, request, ActionListener.wrap(response -> { + long deletedDocs = response.getDeleted(); + if (deletedDocs == 0) { + listener.onFailure(new ApiTokenException("No token found with name " + name)); + } else { + listener.onResponse(null); + } + }, exception -> listener.onFailure(exception))); + } + + public void getTokenMetadatas(ActionListener> listener) { + try { + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder()); + + client.search(searchRequest, ActionListener.wrap(response -> { + try { + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + ApiToken token = ApiToken.fromXContent(parser); + tokens.put(token.getName(), token); + } + } + listener.onResponse(tokens); + } catch (IOException e) { + listener.onFailure(e); + } + }, listener::onFailure)); + } catch (Exception e) { + listener.onFailure(e); + } + } + + public Boolean apiTokenIndexExists() { + return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + } + + public void createApiTokenIndexIfAbsent() { + if (!apiTokenIndexExists()) { + final Map indexSettings = ImmutableMap.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( + indexSettings + ); + client.admin().indices().create(createIndexRequest); + } + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java new file mode 100644 index 0000000000..817bdb23c7 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -0,0 +1,130 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; +import org.opensearch.transport.client.Client; + +import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; + +public class ApiTokenRepository { + private final ApiTokenIndexHandler apiTokenIndexHandler; + private final SecurityTokenManager securityTokenManager; + private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); + + private final Map jtis = new ConcurrentHashMap<>(); + + void reloadApiTokensFromIndex() { + CompletableFuture> future = new CompletableFuture<>(); + + apiTokenIndexHandler.getTokenMetadatas(new ActionListener>() { + @Override + public void onResponse(Map tokensFromIndex) { + future.complete(tokensFromIndex); + } + + @Override + public void onFailure(Exception e) { + future.completeExceptionally(e); + } + }); + + future.thenAccept(tokensFromIndex -> { + jtis.keySet().removeIf(key -> !tokensFromIndex.containsKey(key)); + tokensFromIndex.forEach( + (key, apiToken) -> jtis.put(key, new Permissions(apiToken.getClusterPermissions(), apiToken.getIndexPermissions())) + ); + }); + } + + public Permissions getApiTokenPermissionsForUser(User user) { + String name = user.getName(); + if (name.startsWith(API_TOKEN_USER_PREFIX)) { + String jti = user.getName().split(API_TOKEN_USER_PREFIX)[1]; + if (isValidToken(jti)) { + return getPermissionsForJti(jti); + } + } + return new Permissions(List.of(), List.of()); + } + + public Permissions getPermissionsForJti(String jti) { + return jtis.get(jti); + } + + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); + } + + public Map getJtis() { + return jtis; + } + + public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { + apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + securityTokenManager = tokenManager; + } + + private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + this.apiTokenIndexHandler = apiTokenIndexHandler; + this.securityTokenManager = securityTokenManager; + } + + @VisibleForTesting + static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager); + } + + public void createApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Long expiration, + ActionListener listener + ) { + apiTokenIndexHandler.createApiTokenIndexIfAbsent(); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); + ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration); + apiTokenIndexHandler.indexTokenMetadata( + apiToken, + ActionListener.wrap(unused -> listener.onResponse(token.getCompleteToken()), exception -> listener.onFailure(exception)) + ); + } + + public void deleteApiToken(String name, ActionListener listener) throws ApiTokenException, IndexNotFoundException { + apiTokenIndexHandler.deleteToken(name, listener); + } + + public void getApiTokens(ActionListener> listener) { + apiTokenIndexHandler.getTokenMetadatas(listener); + } + + public void getTokenCount(ActionListener listener) { + getApiTokens(ActionListener.wrap(tokens -> listener.onResponse((long) tokens.size()), listener::onFailure)); + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java new file mode 100644 index 0000000000..c9d324c52f --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import org.opensearch.action.ActionType; + +public class ApiTokenUpdateAction extends ActionType { + + public static final ApiTokenUpdateAction INSTANCE = new ApiTokenUpdateAction(); + public static final String NAME = "cluster:admin/opendistro_security/apitoken/update"; + + protected ApiTokenUpdateAction() { + super(NAME, ApiTokenUpdateResponse::new); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java new file mode 100644 index 0000000000..429310d966 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; + +public class ApiTokenUpdateNodeResponse extends BaseNodeResponse { + public ApiTokenUpdateNodeResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateNodeResponse(DiscoveryNode node) { + super(node); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java new file mode 100644 index 0000000000..f78c0370d5 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ApiTokenUpdateRequest extends BaseNodesRequest { + + public ApiTokenUpdateRequest(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateRequest() throws IOException { + super(new String[0]); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java new file mode 100644 index 0000000000..99d94bd578 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenUpdateResponse extends BaseNodesResponse implements ToXContentObject { + + public ApiTokenUpdateResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateResponse( + final ClusterName clusterName, + List nodes, + List failures + ) { + super(clusterName, nodes, failures); + } + + @Override + public List readNodesFrom(final StreamInput in) throws IOException { + return in.readList(ApiTokenUpdateNodeResponse::new); + } + + @Override + public void writeNodesTo(final StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("ApiTokenupdate_response"); + builder.field("nodes", getNodesMap()); + builder.field("node_size", getNodes().size()); + builder.field("has_failures", hasFailures()); + builder.field("failures_size", failures().size()); + builder.endObject(); + + return builder; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/Permissions.java b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java new file mode 100644 index 0000000000..9b684cebde --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.Collections; +import java.util.List; + +public class Permissions { + private final List clusterPerm; + private final List indexPermission; + + public Permissions(List clusterPerm, List indexPermission) { + this.clusterPerm = clusterPerm; + this.indexPermission = indexPermission; + } + + public Permissions() { + this.clusterPerm = Collections.emptyList(); + this.indexPermission = Collections.emptyList(); + } + + public List getClusterPerm() { + return clusterPerm; + } + + public List getIndexPermission() { + return indexPermission; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java new file mode 100644 index 0000000000..c486deab71 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenRepository apiTokenRepository; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + ApiTokenRepository apiTokenRepository + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenRepository = apiTokenRepository; + this.clusterService = clusterService; + } + + public static class NodeApiTokenUpdateRequest extends TransportRequest { + ApiTokenUpdateRequest request; + + public NodeApiTokenUpdateRequest(ApiTokenUpdateRequest request) { + this.request = request; + } + + public NodeApiTokenUpdateRequest(StreamInput streamInput) throws IOException { + super(streamInput); + this.request = new ApiTokenUpdateRequest(streamInput); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + } + + @Override + protected ApiTokenUpdateNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ApiTokenUpdateNodeResponse(in); + } + + @Override + protected ApiTokenUpdateResponse newResponse( + ApiTokenUpdateRequest request, + List responses, + List failures + ) { + return new ApiTokenUpdateResponse(this.clusterService.getClusterName(), responses, failures); + } + + @Override + protected NodeApiTokenUpdateRequest newNodeRequest(ApiTokenUpdateRequest request) { + return new NodeApiTokenUpdateRequest(request); + } + + @Override + protected ApiTokenUpdateNodeResponse nodeOperation(final NodeApiTokenUpdateRequest request) { + apiTokenRepository.reloadApiTokensFromIndex(); + return new ApiTokenUpdateNodeResponse(clusterService.localNode()); + } +} diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 302ac96442..5b90f46f83 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -73,6 +73,7 @@ import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; import org.opensearch.threadpool.ThreadPool; @@ -92,6 +93,7 @@ public abstract class AbstractAuditLog implements AuditLog { private final Settings settings; private volatile AuditConfig.Filter auditConfigFilter; private final String securityIndex; + private final WildcardMatcher securityIndicesMatcher; private volatile ComplianceConfig complianceConfig; private final Environment environment; private AtomicBoolean externalConfigLogged = new AtomicBoolean(); @@ -124,6 +126,12 @@ protected AbstractAuditLog( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); + this.securityIndicesMatcher = WildcardMatcher.from( + List.of( + settings.get(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX), + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX + ) + ); this.environment = environment; } @@ -477,7 +485,7 @@ public void logDocumentRead(String index, String id, ShardId shardId, Map map = fieldNameValues.entrySet() .stream() @@ -544,7 +552,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index return; } - AuditCategory category = securityIndex.equals(shardId.getIndexName()) + AuditCategory category = securityIndicesMatcher.test(shardId.getIndexName()) ? AuditCategory.COMPLIANCE_INTERNAL_CONFIG_WRITE : AuditCategory.COMPLIANCE_DOC_WRITE; @@ -574,7 +582,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index // originalSource is empty originalSource = "{}"; } - if (securityIndex.equals(shardId.getIndexName())) { + if (securityIndicesMatcher.test(shardId.getIndexName())) { if (originalSource == null) { try ( XContentParser parser = XContentHelper.createParser( @@ -634,7 +642,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } if (!complianceConfig.shouldLogWriteMetadataOnly() && !complianceConfig.shouldLogDiffsForWrite()) { - if (securityIndex.equals(shardId.getIndexName())) { + if (securityIndicesMatcher.test(shardId.getIndexName())) { // current source, normally not null or empty try ( XContentParser parser = XContentHelper.createParser( diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java index a0879cd4da..7b321f2001 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java @@ -10,6 +10,8 @@ */ package org.opensearch.security.authtoken.jwt; +import java.time.Duration; +import java.time.Instant; import java.util.Date; import org.opensearch.identity.tokens.BearerAuthToken; @@ -26,6 +28,13 @@ public ExpiringBearerAuthToken(final String serializedToken, final String subjec this.expiresInSeconds = expiresInSeconds; } + public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry) { + super(serializedToken); + this.subject = subject; + this.expiry = expiry; + this.expiresInSeconds = Duration.between(Instant.now(), expiry.toInstant()).getSeconds(); + } + public String getSubject() { return subject; } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index e21d9257ff..8fd3589ebe 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,12 +11,11 @@ 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; -import java.util.List; -import java.util.Optional; -import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -24,6 +23,7 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.security.authtoken.jwt.claims.JwtClaimsBuilder; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -34,7 +34,6 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.OctetSequenceKey; -import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; @@ -44,21 +43,11 @@ public class JwtVendor { private final JWK signingKey; private final JWSSigner signer; - private final LongSupplier timeProvider; - private final EncryptionDecryptionUtil encryptionDecryptionUtil; - private static final Integer MAX_EXPIRY_SECONDS = 600; - public JwtVendor(final Settings settings, final Optional timeProvider) { + public JwtVendor(Settings settings) { final Tuple tuple = createJwkFromSettings(settings); signingKey = tuple.v1(); signer = tuple.v2(); - - if (isKeyNull(settings, "encryption_key")) { - throw new IllegalArgumentException("encryption_key cannot be null"); - } else { - this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(settings.get("encryption_key")); - } - this.timeProvider = timeProvider.orElse(System::currentTimeMillis); } /* @@ -96,46 +85,14 @@ static Tuple createJwkFromSettings(final Settings settings) { } } - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long requestedExpirySeconds, - final List roles, - final List backendRoles, - final boolean includeBackendRoles - ) throws JOSEException, ParseException { - final long currentTimeMs = timeProvider.getAsLong(); - final Date now = new Date(currentTimeMs); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - final long expirySeconds = Math.min(requestedExpirySeconds, MAX_EXPIRY_SECONDS); - if (expirySeconds <= 0) { - throw new IllegalArgumentException("The expiration time should be a positive integer"); - } - final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); - claimsBuilder.expirationTime(expiryTime); - - if (roles != null) { - final String listOfRoles = String.join(",", roles); - claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); - } else { - throw new IllegalArgumentException("Roles cannot be null"); - } - - if (includeBackendRoles && backendRoles != null) { - final String listOfBackendRoles = String.join(",", backendRoles); - claimsBuilder.claim("br", listOfBackendRoles); - } + @SuppressWarnings("removal") + public ExpiringBearerAuthToken createJwt(JwtClaimsBuilder claimsBuilder, String subject, Date expiryTime, Long expirySeconds) + throws JOSEException, ParseException { final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = new SignedJWT(header, claimsBuilder.build()); + final SignedJWT signedJwt = AccessController.doPrivileged( + (PrivilegedAction) () -> new SignedJWT(header, claimsBuilder.build()) + ); // Sign the JWT so it can be serialized signedJwt.sign(signer); diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java new file mode 100644 index 0000000000..ebb5552045 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +public class ApiJwtClaimsBuilder extends JwtClaimsBuilder { + + public ApiJwtClaimsBuilder() { + super(); + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java new file mode 100644 index 0000000000..2112606b54 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +import java.util.Date; + +import com.nimbusds.jwt.JWTClaimsSet; + +public class JwtClaimsBuilder { + private final JWTClaimsSet.Builder builder; + + public JwtClaimsBuilder() { + this.builder = new JWTClaimsSet.Builder(); + } + + public JwtClaimsBuilder issueTime(Date issueTime) { + builder.issueTime(issueTime); + return this; + } + + public JwtClaimsBuilder notBeforeTime(Date notBeforeTime) { + builder.notBeforeTime(notBeforeTime); + return this; + } + + public JwtClaimsBuilder subject(String subject) { + builder.subject(subject); + return this; + } + + public JwtClaimsBuilder issuer(String issuer) { + builder.issuer(issuer); + return this; + } + + public JwtClaimsBuilder audience(String audience) { + builder.audience(audience); + return this; + } + + public JwtClaimsBuilder issuedAt(Date issuedAt) { + builder.issueTime(issuedAt); + return this; + } + + public JwtClaimsBuilder expirationTime(Date expirationTime) { + builder.expirationTime(expirationTime); + return this; + } + + public JwtClaimsBuilder addCustomClaim(String claimName, String value) { + builder.claim(claimName, value); + return this; + } + + public JWTClaimsSet build() { + return builder.build(); + } + +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java new file mode 100644 index 0000000000..22044a165d --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +import java.util.List; + +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; + +public class OBOJwtClaimsBuilder extends JwtClaimsBuilder { + private final EncryptionDecryptionUtil encryptionDecryptionUtil; + + public OBOJwtClaimsBuilder(String encryptionKey) { + super(); + this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + } + + public OBOJwtClaimsBuilder addRoles(List roles) { + final String listOfRoles = String.join(",", roles); + this.addCustomClaim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); + return this; + } + + public OBOJwtClaimsBuilder addBackendRoles(Boolean includeBackendRoles, List backendRoles) { + if (includeBackendRoles && backendRoles != null) { + final String listOfBackendRoles = String.join(",", backendRoles); + this.addCustomClaim("br", listOfBackendRoles); + } + return this; + } +} diff --git a/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java b/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java index b149f2604a..936cbfa920 100644 --- a/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java +++ b/src/main/java/org/opensearch/security/compliance/ComplianceConfig.java @@ -107,6 +107,7 @@ public class ComplianceConfig { private final String auditLogIndex; private final boolean enabled; private final Supplier dateProvider; + private final WildcardMatcher securityIndicesMatcher; private ComplianceConfig( final boolean enabled, @@ -174,6 +175,7 @@ public WildcardMatcher load(String index) throws Exception { }); this.dateProvider = Optional.ofNullable(dateProvider).orElse(() -> DateTime.now(DateTimeZone.UTC)); + this.securityIndicesMatcher = WildcardMatcher.from(securityIndex, ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); } @VisibleForTesting @@ -508,7 +510,8 @@ public boolean writeHistoryEnabledForIndex(String index) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + // TODO: Add support for custom api token index? + if (this.securityIndicesMatcher.test(index)) { return logInternalConfig; } // if the index is used for audit logging, return false @@ -536,7 +539,7 @@ public boolean readHistoryEnabledForIndex(String index) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + if (securityIndicesMatcher.test(index)) { return logInternalConfig; } try { @@ -558,7 +561,7 @@ public boolean readHistoryEnabledForField(String index, String field) { return false; } // if security index (internal index) check if internal config logging is enabled - if (securityIndex.equals(index)) { + if (securityIndicesMatcher.test(index)) { return logInternalConfig; } WildcardMatcher matcher; diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java index ecc9dcbc59..d5555b445c 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -30,5 +30,6 @@ public enum Endpoint { WHITELIST, ALLOWLIST, NODESDN, - SSL; + SSL, + APITOKENS; } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index faa0217db2..768f9d2f70 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -70,6 +70,7 @@ default String build() { .put(Endpoint.ROLES, action -> buildEndpointPermission(Endpoint.ROLES)) .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) + .put(Endpoint.APITOKENS, action -> buildEndpointPermission(Endpoint.APITOKENS)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) .build(); diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java new file mode 100644 index 0000000000..50b80ad522 --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,223 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.http; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + + public Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final Boolean apiTokenEnabled; + private final String clusterName; + public static final String API_TOKEN_USER_PREFIX = "apitoken:"; + private final ApiTokenRepository apiTokenRepository; + + @SuppressWarnings("removal") + public ApiTokenAuthenticator(Settings settings, String clusterName, ApiTokenRepository apiTokenRepository) { + String apiTokenEnabledSetting = settings.get("enabled", "true"); + apiTokenEnabled = Boolean.parseBoolean(apiTokenEnabledSetting); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); + } + }); + this.clusterName = clusterName; + this.apiTokenRepository = apiTokenRepository; + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find api token authenticator signing_key"); + } + + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); + } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + return jwtParserBuilder; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) + throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request, context); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + final String subject = claims.getSubject(); + if (subject == null) { + log.error("Api token does not have a subject"); + return null; + } + + if (!apiTokenRepository.isValidToken(subject)) { + log.error("Api token is not allowlisted"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this api token does not match the current cluster identifier"); + return null; + } + + return new AuthCredentials(API_TOKEN_USER_PREFIX + subject, List.of(), "").markComplete(); + + } catch (WeakKeyException e) { + log.error("Cannot authenticate api token because of ", e); + return null; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired api token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(SecurityRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "apitoken_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 8a0c3e85f1..f726a8134b 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,9 +11,12 @@ package org.opensearch.security.identity; -import java.util.Optional; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; import java.util.Set; -import java.util.stream.Collectors; +import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -29,6 +32,8 @@ import org.opensearch.identity.tokens.TokenManager; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.ConfigConstants; @@ -39,6 +44,8 @@ import joptsimple.internal.Strings; import org.greenrobot.eventbus.Subscribe; +import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; + /** * This class is the Security Plugin's implementation of the TokenManager used by all Identity Plugins. * It handles the issuance of both Service Account Tokens and On Behalf Of tokens. @@ -50,8 +57,11 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; - private JwtVendor jwtVendor = null; + private Settings oboSettings = null; + private Settings apiTokenSettings = null; private ConfigModel configModel = null; + private final LongSupplier timeProvider = System::currentTimeMillis; + private static final Integer OBO_MAX_EXPIRY_SECONDS = 600; public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { this.cs = cs; @@ -66,19 +76,22 @@ public void onConfigModelChanged(final ConfigModel configModel) { @Subscribe public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { - final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); - final Boolean enabled = oboSettings.getAsBoolean("enabled", false); - if (enabled) { - jwtVendor = createJwtVendor(oboSettings); - } else { - jwtVendor = null; + final Settings oboSettingsFromDcm = dcm.getDynamicOnBehalfOfSettings(); + final Boolean oboEnabled = oboSettingsFromDcm.getAsBoolean("enabled", false); + if (oboEnabled) { + oboSettings = oboSettingsFromDcm; + } + final Settings apiTokenSettingsFromDcm = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettingsFromDcm.getAsBoolean("enabled", false); + if (apiTokenEnabled) { + apiTokenSettings = apiTokenSettingsFromDcm; } } /** For testing */ JwtVendor createJwtVendor(final Settings settings) { try { - return new JwtVendor(settings, Optional.empty()); + return new JwtVendor(settings); } catch (final Exception ex) { logger.error("Unable to create the JwtVendor instance", ex); return null; @@ -86,7 +99,11 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return jwtVendor != null && configModel != null; + return oboSettings != null && configModel != null; + } + + public boolean issueApiTokenAllowed() { + return apiTokenSettings != null && configModel != null; } @Override @@ -115,22 +132,74 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final long expirySeconds = Math.min(claims.getExpiration(), OBO_MAX_EXPIRY_SECONDS); + if (expirySeconds <= 0) { + throw new IllegalArgumentException("The expiration time should be a positive integer"); + } + if (mappedRoles == null) { + throw new IllegalArgumentException("Roles cannot be null"); + } + if (isKeyNull(oboSettings, "encryption_key")) { + throw new IllegalArgumentException("encryption_key cannot be null"); + } + + final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder(oboSettings.get("encryption_key")); + + // Add obo claims + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(user.getName()); + claimsBuilder.audience(claims.getAudience()); + claimsBuilder.notBeforeTime(now); + claimsBuilder.addBackendRoles(false, new ArrayList<>(user.getRoles())); + claimsBuilder.addRoles(new ArrayList<>(mappedRoles)); + + final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); + claimsBuilder.expirationTime(expiryTime); + try { - return jwtVendor.createJwt( - cs.getClusterName().value(), - user.getName(), - claims.getAudience(), - claims.getExpiration(), - mappedRoles.stream().collect(Collectors.toList()), - user.getRoles().stream().collect(Collectors.toList()), - false - ); + return createJwtVendor(oboSettings).createJwt(claimsBuilder, user.getName(), expiryTime, expirySeconds); } catch (final Exception ex) { logger.error("Error creating OnBehalfOfToken for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate OnBehalfOfToken"); } } + public ExpiringBearerAuthToken issueApiToken(final String name, final Long expiration) { + if (!issueApiTokenAllowed()) { + throw new OpenSearchSecurityException("Api token generation is not enabled."); + } + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final ApiJwtClaimsBuilder claimsBuilder = new ApiJwtClaimsBuilder(); + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(name); + claimsBuilder.audience(name); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + + try { + return createJwtVendor(apiTokenSettings).createJwt( + claimsBuilder, + name, + expiryTime, + Duration.between(Instant.now(), expiryTime.toInstant()).getSeconds() + ); + } catch (final Exception ex) { + logger.error("Error creating Api Token for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate Api Token"); + } + } + @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index eb560ed901..dcb6cded2d 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -13,7 +13,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -35,6 +37,8 @@ import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -141,11 +145,29 @@ public ActionPrivileges( } public PrivilegesEvaluatorResponse hasClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), false); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationContext context, Set actions) { - return cluster.providesAnyPrivilege(context, actions, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesAnyPrivilege((RoleBasedPrivilegesEvaluationContext) context, actions, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, actions, false); + } else { + // Not supported + if (actions.size() == 1) { + return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); + } else { + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } + } } /** @@ -159,7 +181,14 @@ public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationCo * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ public PrivilegesEvaluatorResponse hasExplicitClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesExplicitPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesExplicitPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), true); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } /** @@ -177,44 +206,64 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); - if (response != null) { - return response; - } + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); + if (response != null) { + return response; + } - if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { - // This is necessary for requests which operate on remote indices. - // Access control for the remote indices will be performed on the remote cluster. - log.debug("No local indices; grant the request"); - return PrivilegesEvaluatorResponse.ok(); - } + if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { + // This is necessary for requests which operate on remote indices. + // Access control for the remote indices will be performed on the remote cluster. + log.debug("No local indices; grant the request"); + return PrivilegesEvaluatorResponse.ok(); + } - // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart - // what's the action and what's the index in the generic parameters of CheckTable. - CheckTable checkTable = CheckTable.create( - resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), - actions - ); + // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart + // what's the action and what's the index in the generic parameters of CheckTable. + CheckTable checkTable = CheckTable.create( + resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), + actions + ); - StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); - PrivilegesEvaluatorResponse resultFromStatefulIndex = null; + StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); + PrivilegesEvaluatorResponse resultFromStatefulIndex = null; - Map indexMetadata = this.indexMetadataSupplier.get(); + Map indexMetadata = this.indexMetadataSupplier.get(); - if (statefulIndex != null) { - resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); + if (statefulIndex != null) { + resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); - if (resultFromStatefulIndex != null) { - // If we get a result from statefulIndex, we are done. - return resultFromStatefulIndex; + if (resultFromStatefulIndex != null) { + // If we get a result from statefulIndex, we are done. + return resultFromStatefulIndex; + } + + // Otherwise, we need to carry on checking privileges using the non-stateful object. + // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. + // We can carry on using this as an intermediate result and further complete checkTable below. } + return this.index.providesPrivilege( + (RoleBasedPrivilegesEvaluationContext) context, + actions, + resolvedIndices, + checkTable, + indexMetadata + ); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + Map indexMetadata = this.indexMetadataSupplier.get(); + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + indexMetadata, + false + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("No explicit privileges have been provided for the referenced indices."); - // Otherwise, we need to carry on checking privileges using the non-stateful object. - // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. - // We can carry on using this as an intermediate result and further complete checkTable below. } - - return this.index.providesPrivilege(context, actions, resolvedIndices, checkTable, indexMetadata); } /** @@ -229,8 +278,21 @@ public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); - return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); + return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + this.indexMetadataSupplier.get(), + true + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } } /** @@ -322,6 +384,8 @@ static class ClusterPrivileges { private final ImmutableSet wellKnownClusterActions; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed cluster privileges based on the given parameters. *

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

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

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

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

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

+ * As this class is request-scoped, it is only used by a single thread. Thus, no thread synchronization mechanisms + * are necessary. + */ +public class RoleBasedPrivilegesEvaluationContext extends PrivilegesEvaluationContext { + private ImmutableSet mappedRoles; + + public RoleBasedPrivilegesEvaluationContext( + User user, + ImmutableSet mappedRoles, + String action, + ActionRequest request, + Task task, + IndexResolverReplacer indexResolverReplacer, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier clusterStateSupplier + ) { + super(user, action, request, task, indexResolverReplacer, indexNameExpressionResolver, clusterStateSupplier); + this.mappedRoles = mappedRoles; + } + + @Override + public ImmutableSet getMappedRoles() { + return mappedRoles; + } + + /** + * Note: Ideally, mappedRoles would be an unmodifiable attribute. PrivilegesEvaluator however contains logic + * related to OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION which first validates roles and afterwards modifies + * them again. Thus, we need to be able to set this attribute. + * + * However, this method should be only used for this one particular phase. Normally, all roles should be determined + * upfront and stay constant during the whole privilege evaluation process. + */ + @Override + void setMappedRoles(ImmutableSet mappedRoles) { + this.mappedRoles = mappedRoles; + } + + @Override + public String toString() { + return "RoleBasedPrivilegesEvaluationContext{" + + "user=" + + getUser() + + ", action='" + + getAction() + + '\'' + + ", request=" + + getRequest() + + ", resolvedRequest=" + + getResolvedRequest() + + ", mappedRoles=" + + mappedRoles + + '}'; + } +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index c427a46685..8886794d5f 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -42,6 +42,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.config.AuditConfig; import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; @@ -127,6 +128,7 @@ public final static SecurityDynamicConfiguration addStatics(SecurityDynam private final Path configPath; private final InternalAuthenticationBackend iab; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; SecurityDynamicConfiguration config; @@ -137,7 +139,8 @@ public DynamicConfigFactory( Client client, ThreadPool threadPool, ClusterInfoHolder cih, - PasswordHasher passwordHasher + PasswordHasher passwordHasher, + ApiTokenRepository apiTokenRepository ) { super(); this.cr = cr; @@ -145,6 +148,7 @@ public DynamicConfigFactory( this.configPath = configPath; this.cih = cih; this.iab = new InternalAuthenticationBackend(passwordHasher); + this.apiTokenRepository = apiTokenRepository; if (opensearchSettings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES, true)) { try { @@ -269,7 +273,7 @@ public void onChange(ConfigurationMap typeToConfig) { ); // rebuild v7 Models - dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); + dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih, apiTokenRepository); ium = new InternalUsersModelV7(internalusers, roles, rolesmapping); cm = new ConfigModelV7(roles, rolesmapping, actionGroups, tenants, dcm, opensearchSettings); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java index 064f555a75..0d56a41c23 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java @@ -110,6 +110,8 @@ public abstract class DynamicConfigModel { public abstract Settings getDynamicOnBehalfOfSettings(); + public abstract Settings getDynamicApiTokenSettings(); + protected final Map authImplMap = new HashMap<>(); public DynamicConfigModel() { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 4bc9e82882..55271e960b 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -49,6 +49,7 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auth.AuthDomain; import org.opensearch.security.auth.AuthFailureListener; import org.opensearch.security.auth.AuthenticationBackend; @@ -59,6 +60,7 @@ import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.v7.ConfigV7; @@ -85,13 +87,15 @@ public class DynamicConfigModelV7 extends DynamicConfigModel { private List> ipClientBlockRegistries; private Multimap> authBackendClientBlockRegistries; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; public DynamicConfigModelV7( ConfigV7 config, Settings opensearchSettings, Path configPath, InternalAuthenticationBackend iab, - ClusterInfoHolder cih + ClusterInfoHolder cih, + ApiTokenRepository apiTokenRepository ) { super(); this.config = config; @@ -99,6 +103,7 @@ public DynamicConfigModelV7( this.configPath = configPath; this.iab = iab; this.cih = cih; + this.apiTokenRepository = apiTokenRepository; buildAAA(); } @@ -234,6 +239,13 @@ public Settings getDynamicOnBehalfOfSettings() { .build(); } + @Override + public Settings getDynamicApiTokenSettings() { + return Settings.builder() + .put(Settings.builder().loadFromSource(config.dynamic.api_tokens.configAsJson(), XContentType.JSON).build()) + .build(); + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); @@ -370,6 +382,23 @@ private void buildAAA() { } } + /* + * If the Api token authentication is configured: + * Add the ApiToken authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when ApiToken authentication failed + * order: -2 - prioritize the Api token authentication when it gets enabled + */ + Settings apiTokenSettings = getDynamicApiTokenSettings(); + if (!isKeyNull(apiTokenSettings, "signing_key")) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName(), apiTokenRepository), + false, + -2 + ); + restAuthDomains0.add(_ad); + } + /* * If the OnBehalfOf (OBO) authentication is configured: * Add the OBO authbackend in to the auth domains diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 77fb973a52..d960a9e9bd 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -86,6 +86,7 @@ public static class Dynamic { public String transport_userrname_attribute; public boolean do_not_fail_on_forbidden_empty; public OnBehalfOfSettings on_behalf_of = new OnBehalfOfSettings(); + public ApiTokenSettings api_tokens = new ApiTokenSettings(); @Override public String toString() { @@ -101,6 +102,8 @@ public String toString() { + authz + ", on_behalf_of=" + on_behalf_of + + ", api_tokens=" + + api_tokens + "]"; } } @@ -495,4 +498,42 @@ public String toString() { } } + public static class ApiTokenSettings { + @JsonProperty("enabled") + private Boolean enabled = Boolean.FALSE; + @JsonProperty("signing_key") + private String signingKey; + + @JsonIgnore + public String configAsJson() { + try { + return DefaultObjectMapper.writeValueAsString(this, false); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean apiTokensEnabled) { + this.enabled = apiTokensEnabled; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + @Override + public String toString() { + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + "]"; + } + + } + } diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 4683075f1d..32a70a468f 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -68,6 +68,10 @@ public static OpenSearchException invalidUsageOfOBOTokenException() { return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); } + public static OpenSearchException invalidUsageOfApiTokenException() { + return new OpenSearchException("Api Tokens are not allowed to be used for accessing this endpoint."); + } + public static OpenSearchException createJwkCreationException() { return new OpenSearchException("An error occurred during the creation of Jwk."); } diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index b2a9387c9a..e687d5cf99 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -373,6 +373,8 @@ public enum RolesMappingResolution { // Variable for initial admin password support public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + public static final String OPENSEARCH_API_TOKENS_INDEX = ".opensearch_security_api_tokens"; + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java index 3884bf75fe..caccb91407 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -20,6 +20,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String API_TOKEN_SUFFIX = "api/apitokens"; public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { if (suffix == null) { @@ -28,6 +29,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques switch (suffix) { case ON_BEHALF_OF_SUFFIX: return request.method() == POST; + case API_TOKEN_SUFFIX: + // Don't want to allow any api token access + return true; case ACCOUNT_SUFFIX: return request.method() == PUT; default: diff --git a/src/main/java/org/opensearch/security/util/ParsingUtils.java b/src/main/java/org/opensearch/security/util/ParsingUtils.java new file mode 100644 index 0000000000..1a33ec46b4 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/ParsingUtils.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ParsingUtils { + + /** + * Safely casts an Object to List with validation + */ + public static List safeStringList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof String)) { + throw new IllegalArgumentException(fieldName + " must contain only strings"); + } + } + + return list.stream().map(String.class::cast).collect(Collectors.toList()); + } + + /** + * Safely casts an Object to List> with validation + */ + @SuppressWarnings("unchecked") + public static List> safeMapList(Object obj, String fieldName) { + if (obj == null) { + return Collections.emptyList(); + } + if (!(obj instanceof List list)) { + throw new IllegalArgumentException(fieldName + " must be an array"); + } + + for (Object item : list) { + if (!(item instanceof Map)) { + throw new IllegalArgumentException(fieldName + " must contain object entries"); + } + } + return list.stream().map(item -> (Map) item).collect(Collectors.toList()); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java new file mode 100644 index 0000000000..7c52f07ae7 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -0,0 +1,235 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.threadpool.ThreadPool; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration.fromMap; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenActionTest { + @Mock + private ThreadPool threadPool; + + @Mock + private PrivilegesEvaluator privilegesEvaluator; + + @Mock + private ConfigurationRepository configurationRepository; + + @Mock + private ClusterService clusterService; + @Mock + private ClusterState clusterState; + + @Mock + private Metadata metadata; + + private SecurityDynamicConfiguration actionGroupsConfig; + private SecurityDynamicConfiguration rolesConfig; + private FlattenedActionGroups flattenedActionGroups; + private ApiTokenAction apiTokenAction; + + @Before + public void setUp() throws JsonProcessingException { + // Setup basic action groups + + actionGroupsConfig = SecurityDynamicConfiguration.fromMap( + ImmutableMap.of( + "read_group", + Map.of("allowed_actions", List.of("read", "get", "search")), + "write_group", + Map.of("allowed_actions", List.of("write", "create", "index")) + ), + CType.ACTIONGROUPS + ); + + rolesConfig = fromMap( + ImmutableMap.of( + "read_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "read_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "more_permissable_write_group_lo-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "cluster_monitor", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ), + "alias_group", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs"), "allowed_actions", List.of("read"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ) + + ), + CType.ROLES + ); + + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + + apiTokenAction = new ApiTokenAction( + + threadPool, + configurationRepository, + privilegesEvaluator, + Settings.EMPTY, + null, + null, + null, + null, + null, + clusterService, + null + ); + + } + + @Test + public void testCreateIndexPermission() { + Map validPermission = new HashMap<>(); + validPermission.put("index_pattern", "test-*"); + validPermission.put("allowed_actions", List.of("read")); + + ApiToken.IndexPermission result = apiTokenAction.createIndexPermission(validPermission); + + assertThat(result.getIndexPatterns(), is(List.of("test-*"))); + assertThat(result.getAllowedActions(), is(List.of("read"))); + } + + @Test + public void testValidateRequestParameters() { + Map validRequest = new HashMap<>(); + validRequest.put("name", "test-token"); + validRequest.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + apiTokenAction.validateRequestParameters(validRequest); + + // Missing name + Map missingName = new HashMap<>(); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(missingName)); + + // Invalid cluster_permissions type + Map invalidClusterPerms = new HashMap<>(); + invalidClusterPerms.put("name", "test"); + invalidClusterPerms.put("cluster_permissions", "not a list"); + assertThrows(IllegalArgumentException.class, () -> apiTokenAction.validateRequestParameters(invalidClusterPerms)); + } + + @Test + public void testValidateIndexPermissionsList() { + Map validPerm = new HashMap<>(); + validPerm.put("index_pattern", "test-*"); + validPerm.put("allowed_actions", List.of("read")); + apiTokenAction.validateIndexPermissionsList(Collections.singletonList(validPerm)); + + // Missing index_pattern + Map missingPattern = new HashMap<>(); + missingPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingPattern)) + ); + + // Missing allowed_actions + Map missingActions = new HashMap<>(); + missingActions.put("index_pattern", "test-*"); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(missingActions)) + ); + + // Invalid index_pattern type + Map invalidPattern = new HashMap<>(); + invalidPattern.put("index_pattern", 123); + invalidPattern.put("allowed_actions", List.of("read")); + assertThrows( + IllegalArgumentException.class, + () -> apiTokenAction.validateIndexPermissionsList(Collections.singletonList(invalidPattern)) + ); + } + + @Test + public void testExtractClusterPermissions() { + Map requestBody = new HashMap<>(); + + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(empty())); + + requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); + assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..b6c5e0b0f1 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; + +import org.apache.logging.log4j.Logger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.security.user.AuthCredentials; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenAuthenticatorTest { + + private ApiTokenAuthenticator authenticator; + @Mock + private Logger log; + + @Mock + private ApiTokenRepository apiTokenRepository; + + private ThreadContext threadcontext; + private final String signingKey = Base64.getEncoder() + .encodeToString("jwt signing key long enough for secure api token authentication testing".getBytes(StandardCharsets.UTF_8)); + private final String tokenName = "test-token"; + + @Before + public void setUp() { + Settings settings = Settings.builder().put("enabled", "true").put("signing_key", signingKey).build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + when(log.isDebugEnabled()).thenReturn(true); + threadcontext = new ThreadContext(Settings.EMPTY); + } + + @Test + public void testAuthenticationFailsWhenJtiNotInCache() { + String testJti = "test-jti-not-in-cache"; + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + AuthCredentials credentials = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is not in allowlist cache", credentials); + } + + @Test + public void testExtractCredentialsPassWhenJtiInCache() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNotNull("Should not be null when JTI is in allowlist cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().minus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is expired", ac); + verify(log).debug(eq("Invalid or expired api token."), any(ExpiredJwtException.class)); + + } + + @Test + public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { + String token = Jwts.builder() + .setIssuer("not-opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when issuer does not match cluster", ac); + verify(log).error(eq("The issuer of this api token does not match the current cluster identifier")); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is being used to access restricted endpoint", ac); + verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); + } + + @Test + public void testAuthenticatorNotEnabled() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + + Settings settings = Settings.builder() + .put("enabled", "false") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when api tokens auth is not enabled", ac); + verify(log).error(eq("Api token authentication is disabled")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java new file mode 100644 index 0000000000..b74e7073fc --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -0,0 +1,302 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.apache.lucene.search.TotalHits; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.index.query.MatchQueryBuilder; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.util.ActionListenerUtils.TestActionListener; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.client.IndicesAdminClient; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class ApiTokenIndexHandlerTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testCreateApiTokenIndexWhenIndexNotExist() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(false); + + indexHandler.createApiTokenIndexIfAbsent(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateIndexRequest.class); + verify(indicesAdminClient).create(captor.capture()); + assertThat(captor.getValue().index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + } + + @Test + public void testCreateApiTokenIndexWhenIndexExists() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + indexHandler.createApiTokenIndexIfAbsent(); + + verifyNoInteractions(indicesAdminClient); + } + + @Test + public void testDeleteApiTokeCallsDeleteByQueryWithSuppliedName() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + String tokenName = "token"; + + TestActionListener listener = new TestActionListener<>(); + + doAnswer(invocation -> { + DeleteByQueryRequest request = invocation.getArgument(1); + ActionListener parentListener = invocation.getArgument(2); + + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + + parentListener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + indexHandler.deleteToken(tokenName, listener); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteByQueryRequest.class); + verify(client).execute(eq(DeleteByQueryAction.INSTANCE), captor.capture(), any(ActionListener.class)); + + listener.assertSuccess(); + + DeleteByQueryRequest capturedRequest = captor.getValue(); + MatchQueryBuilder query = (MatchQueryBuilder) capturedRequest.getSearchRequest().source().query(); + assertThat(query.fieldName(), equalTo(NAME_FIELD)); + assertThat(query.value(), equalTo(tokenName)); + } + + @Test + public void testDeleteTokenThrowsExceptionWhenNoDocumentsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(0L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + String tokenName = "nonexistent-token"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("No token found with name " + tokenName)); + } + + @Test + public void testDeleteTokenSucceedsWhenDocumentIsDeleted() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(2); + BulkByScrollResponse response = mock(BulkByScrollResponse.class); + when(response.getDeleted()).thenReturn(1L); + listener.onResponse(response); + return null; + }).when(client).execute(eq(DeleteByQueryAction.INSTANCE), any(DeleteByQueryRequest.class), any(ActionListener.class)); + + String tokenName = "existing-token"; + TestActionListener listener = new TestActionListener<>(); + indexHandler.deleteToken(tokenName, listener); + + listener.assertSuccess(); + } + + @Test + public void testIndexTokenStoresTokenPayload() { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + List clusterPermissions = Arrays.asList("cluster:admin/something"); + List indexPermissions = Arrays.asList( + new ApiToken.IndexPermission( + Arrays.asList("test-index-*"), + Arrays.asList("read", "write") + ) + ); + ApiToken token = new ApiToken( + "test-token-description", + clusterPermissions, + indexPermissions, + Instant.now(), + Long.MAX_VALUE + ); + + // Mock the index response + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(mock(IndexResponse.class)); + return null; + }).when(client).index(any(IndexRequest.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + indexHandler.indexTokenMetadata(token, listener); + + listener.assertSuccess(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(client).index(requestCaptor.capture(), any(ActionListener.class)); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.index(), equalTo(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)); + + String source = capturedRequest.source().utf8ToString(); + assertThat(source, containsString("test-token-description")); + assertThat(source, containsString("cluster:admin/something")); + assertThat(source, containsString("test-index-*")); + } + + @Test + public void testGetTokenPayloads() throws IOException { + when(metadata.hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(true); + + // Create sample search hits + SearchHit[] hits = new SearchHit[2]; + + // First token + ApiToken token1 = new ApiToken( + "token1-description", + Arrays.asList("cluster:admin/something"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index1-*"), + Arrays.asList("read") + )), + Instant.now(), + Long.MAX_VALUE + ); + + // Second token + ApiToken token2 = new ApiToken( + "token2-description", + Arrays.asList("cluster:admin/other"), + Arrays.asList(new ApiToken.IndexPermission( + Arrays.asList("index2-*"), + Arrays.asList("write") + )), + Instant.now(), + Long.MAX_VALUE + ); + + // Convert tokens to XContent and create SearchHits + XContentBuilder builder1 = XContentBuilder.builder(XContentType.JSON.xContent()); + token1.toXContent(builder1, ToXContent.EMPTY_PARAMS); + hits[0] = new SearchHit(1, "1", null, null); + hits[0].sourceRef(BytesReference.bytes(builder1)); + + XContentBuilder builder2 = XContentBuilder.builder(XContentType.JSON.xContent()); + token2.toXContent(builder2, ToXContent.EMPTY_PARAMS); + hits[1] = new SearchHit(2, "2", null, null); + hits[1].sourceRef(BytesReference.bytes(builder2)); + + // Create and mock search response + SearchResponse searchResponse = mock(SearchResponse.class); + SearchHits searchHits = new SearchHits(hits, new TotalHits(2, TotalHits.Relation.EQUAL_TO), 1.0f); + when(searchResponse.getHits()).thenReturn(searchHits); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(searchResponse); + return null; + }).when(client).search(any(SearchRequest.class), any(ActionListener.class)); + + TestActionListener> listener = new TestActionListener<>(); + indexHandler.getTokenMetadatas(listener); + + Map resultTokens = listener.assertSuccess(); + assertThat(resultTokens.size(), equalTo(2)); + assertThat(resultTokens.containsKey("token1-description"), is(true)); + assertThat(resultTokens.containsKey("token2-description"), is(true)); + + ApiToken resultToken1 = resultTokens.get("token1-description"); + assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); + + ApiToken resultToken2 = resultTokens.get("token2-description"); + assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java new file mode 100644 index 0000000000..a7a43cb862 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -0,0 +1,255 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.core.action.ActionListener; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; +import org.opensearch.security.util.ActionListenerUtils.TestActionListener; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenRepositoryTest { + @Mock + private SecurityTokenManager securityTokenManager; + @Mock + private ApiTokenIndexHandler apiTokenIndexHandler; + private ApiTokenRepository repository; + + @Before + public void setUp() { + apiTokenIndexHandler = mock(ApiTokenIndexHandler.class); + securityTokenManager = mock(SecurityTokenManager.class); + repository = ApiTokenRepository.forTest(apiTokenIndexHandler, securityTokenManager); + } + + @Test + public void testDeleteApiToken() throws ApiTokenException { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + listener.assertSuccess(); + verify(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + } + + @Test + public void testGetApiTokenPermissionsForUser() throws ApiTokenException { + User derek = new User("derek"); + User apiTokenNotExists = new User("apitoken:notexists"); + User apiTokenExists = new User("apitoken:exists"); + repository.getJtis() + .put("exists", new Permissions(List.of("cluster_all"), List.of(new ApiToken.IndexPermission(List.of("*"), List.of("*"))))); + + Permissions permissionsForDerek = repository.getApiTokenPermissionsForUser(derek); + assertEquals(List.of(), permissionsForDerek.getClusterPerm()); + assertEquals(List.of(), permissionsForDerek.getIndexPermission()); + + Permissions permissionsForApiTokenNotExists = repository.getApiTokenPermissionsForUser(apiTokenNotExists); + assertEquals(List.of(), permissionsForApiTokenNotExists.getClusterPerm()); + assertEquals(List.of(), permissionsForApiTokenNotExists.getIndexPermission()); + + Permissions permissionsForApiTokenExists = repository.getApiTokenPermissionsForUser(apiTokenExists); + assertEquals(List.of("cluster_all"), permissionsForApiTokenExists.getClusterPerm()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getAllowedActions()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getIndexPatterns()); + } + + @Test + public void testGetApiTokens() throws IndexNotFoundException { + Map expectedTokens = new HashMap<>(); + expectedTokens.put("token1", new ApiToken("token1", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(expectedTokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener> listener = new TestActionListener<>(); + repository.getApiTokens(listener); + + Map result = listener.assertSuccess(); + assertThat(result, equalTo(expectedTokens)); + verify(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + } + + @Test + public void testCreateApiToken() { + String tokenName = "test-token"; + List clusterPermissions = Arrays.asList("cluster:admin"); + List indexPermissions = Arrays.asList( + new ApiToken.IndexPermission(Arrays.asList("test-*"), Arrays.asList("read")) + ); + Long expiration = 3600L; + + String completeToken = "complete-token"; + ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); + when(bearerToken.getCompleteToken()).thenReturn(completeToken); + when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onResponse(null); + return null; + }).when(apiTokenIndexHandler).indexTokenMetadata(any(ApiToken.class), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener() { + @Override + public void onResponse(String result) { + try { + assertThat(result, equalTo(completeToken)); + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); + verify(securityTokenManager).issueApiToken(any(), any()); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) + ), + any(ActionListener.class) + ); + } finally { + super.onResponse(result); + } + } + }; + + repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration, listener); + listener.assertSuccess(); + } + + @Test + public void testGetApiTokensThrowsIndexNotFoundException() { + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onFailure(new IndexNotFoundException("test-index")); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + TestActionListener> listener = new TestActionListener<>(); + repository.getApiTokens(listener); + + Exception e = listener.assertException(IndexNotFoundException.class); + assertThat(e.getMessage(), containsString("test-index")); + } + + @Test + public void testDeleteApiTokenThrowsApiTokenException() { + String tokenName = "test-token"; + + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + listener.onFailure(new ApiTokenException("Token not found")); + return null; + }).when(apiTokenIndexHandler).deleteToken(eq(tokenName), any(ActionListener.class)); + + TestActionListener listener = new TestActionListener<>(); + repository.deleteApiToken(tokenName, listener); + + Exception e = listener.assertException(ApiTokenException.class); + assertThat(e.getMessage(), containsString("Token not found")); + } + + @Test + public void testJtisOperations() { + String jti = "testJti"; + Permissions permissions = new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of()))); + + repository.getJtis().put(jti, permissions); + assertEquals("Should retrieve correct permissions", permissions, repository.getJtis().get(jti)); + + repository.getJtis().remove(jti); + assertNull("Should return null after removal", repository.getJtis().get(jti)); + } + + @Test + public void testClearJtis() { + repository.getJtis().put("testJti", new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(Collections.emptyMap()); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + repository.reloadApiTokensFromIndex(); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty())); + } + + @Test + public void testReloadApiTokensFromIndexAndParse() throws IOException { + // Setup mock response + Map expectedTokens = Map.of("test", new ApiToken("test", List.of("cluster:monitor"), List.of(), Long.MAX_VALUE)); + + doAnswer(invocation -> { + ActionListener> listener = invocation.getArgument(0); + listener.onResponse(expectedTokens); + return null; + }).when(apiTokenIndexHandler).getTokenMetadatas(any(ActionListener.class)); + + // Execute the reload + repository.reloadApiTokensFromIndex(); + + // Wait for and verify the async updates + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertFalse("Jtis should not be empty after reload", repository.getJtis().isEmpty()); + assertEquals("Should have one JTI entry", 1, repository.getJtis().size()); + assertTrue("Should contain testJti", repository.getJtis().containsKey("test")); + assertEquals("Should have one cluster action", List.of("cluster:monitor"), repository.getJtis().get("test").getClusterPerm()); + assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndexPermission()); + }); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java new file mode 100644 index 0000000000..922bfaff1e --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.client.IndicesAdminClient; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiTokenTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testIndexPermissionToStringFromString() throws IOException { + String indexPermissionString = "{\"index_pattern\":[\"index1\",\"index2\"],\"allowed_actions\":[\"action1\",\"action2\"]}"; + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission( + Arrays.asList("index1", "index2"), + Arrays.asList("action1", "action2") + ); + assertThat( + indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString(), + equalTo(indexPermissionString) + ); + + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, indexPermissionString); + + ApiToken.IndexPermission indexPermissionFromString = ApiToken.IndexPermission.fromXContent(parser); + assertThat(indexPermissionFromString.getIndexPatterns(), equalTo(List.of("index1", "index2"))); + assertThat(indexPermissionFromString.getAllowedActions(), equalTo(List.of("action1", "action2"))); + } + +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index e0026155de..2ab7b9da8e 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -27,6 +27,17 @@ public class AuthTokenUtilsTest { + @Test + public void testIsAccessToRestrictedEndpointsForApiToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/apitokens") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + @Test public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index ca8b4ad14d..6112f2794f 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -14,7 +14,6 @@ import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; -import java.util.Optional; import java.util.function.LongSupplier; import com.google.common.io.BaseEncoding; @@ -30,6 +29,8 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.support.ConfigConstants; import com.nimbusds.jose.JWSSigner; @@ -41,7 +42,6 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; import static org.junit.Assert.assertThrows; @@ -66,7 +66,7 @@ public void testCreateJwkFromSettings() { final Tuple jwk = JwtVendor.createJwkFromSettings(settings); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); assertThat(jwk.v1().getKeyUse().toString(), is("sig")); - Assert.assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); + assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); } @Test @@ -100,8 +100,21 @@ public void testCreateJwtWithRoles() throws Exception { String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor OBOJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(false, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -137,8 +150,21 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) // CS-ENFORCE-SINGLE .build(); - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -154,87 +180,6 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("er").toString()), equalTo(expectedRoles)); } - @Test - public void testCreateJwtWithNegativeExpiry() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("admin"); - Integer expirySeconds = -300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); - } - - @Test - public void testCreateJwtWithExceededExpiry() throws Exception { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("IT", "HR"); - List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 900_000; - LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. - assertThat(authToken.getExpiresInSeconds(), not(equalTo(expirySeconds))); - assertThat(authToken.getExpiresInSeconds(), equalTo(600L)); - } - - @Test - public void testCreateJwtWithBadEncryptionKey() { - final String issuer = "cluster_0"; - final String subject = "admin"; - final String audience = "audience_0"; - final List roles = List.of("admin"); - final Integer expirySeconds = 300; - - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - new JwtVendor(settings, Optional.empty()).createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); - } - - @Test - public void testCreateJwtWithBadRoles() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = null; - Integer expirySeconds = 300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); - } - @Test public void testCreateJwtLogsCorrectly() throws Exception { mockAppender = mock(Appender.class); @@ -255,11 +200,22 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String audience = "audience_0"; final List roles = List.of("IT", "HR"); final List backendRoles = List.of("Sales", "Support"); - final int expirySeconds = 300; - - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + int expirySeconds = 300; - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); verify(mockAppender, times(1)).append(logEventCaptor.capture()); @@ -270,4 +226,49 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String[] parts = logMessage.split("\\."); assertTrue(parts.length >= 3); } + + @Test + public void testCreateApiTokenJwtSuccess() throws Exception { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + int expirySeconds = 300; + // 2023 oct 4, 10:00:00 AM GMT + LongSupplier currentTime = () -> 1696413600000L; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); + + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor apiTokenJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = apiTokenJwtVendor.createJwt( + new ApiJwtClaimsBuilder().issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); + + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); + + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[audience_0]")); + // 2023 oct 4, 10:00:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("iat")).getTime(), is(1696413600000L)); + // 2023 oct 4, 10:05:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime(), is(1696413900000L)); + } + + @Test + public void testKeyTooShortForApiTokenThrowsException() { + String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); + Settings settings = Settings.builder().put("signing_key", tooShortKey).build(); + final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings); }); + + assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); + } + } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index d686b145b2..90531dfdb8 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -12,9 +12,11 @@ package org.opensearch.security.identity; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; +import com.google.common.io.BaseEncoding; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -37,16 +39,15 @@ import org.opensearch.security.user.UserService; import org.opensearch.threadpool.ThreadPool; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -76,11 +77,14 @@ public void setup() { @After public void after() { - verifyNoMoreInteractions(cs); - verifyNoMoreInteractions(threadPool); verifyNoMoreInteractions(userService); } + final static String signingKey = + "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; + final static String signingKeyB64Encoded = BaseEncoding.base64().encode(signingKey.getBytes(StandardCharsets.UTF_8)); + + @Test public void onConfigModelChanged_oboNotSupported() { final ConfigModel configModel = mock(ConfigModel.class); @@ -93,7 +97,7 @@ public void onConfigModelChanged_oboNotSupported() { @Test public void onDynamicConfigModelChanged_JwtVendorEnabled() { final ConfigModel configModel = mock(ConfigModel.class); - final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(); + final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(true); tokenManager.onConfigModelChanged(configModel); @@ -107,6 +111,7 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { final Settings settings = Settings.builder().put("enabled", false).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); tokenManager.onDynamicConfigModelChanged(dcm); assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); @@ -115,10 +120,15 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { } /** Creates the jwt vendor and returns a mock for validation if needed */ - private DynamicConfigModel createMockJwtVendorInTokenManager() { - final Settings settings = Settings.builder().put("enabled", true).build(); + private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncryptionKey) { + final Settings settings = Settings.builder() + .put("enabled", true) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", (includeEncryptionKey ? "1234567890" : null)) + .build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); tokenManager.onDynamicConfigModelChanged(dcm); return dcm; @@ -208,11 +218,9 @@ public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenThrow( - new RuntimeException("foobar") - ); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenThrow(new RuntimeException("foobar")); final OpenSearchSecurityException exception = assertThrows( OpenSearchSecurityException.class, () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) @@ -234,10 +242,10 @@ public void issueOnBehalfOfToken_success() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenReturn(authToken); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)); assertThat(returnedToken, equalTo(authToken)); @@ -245,4 +253,112 @@ public void issueOnBehalfOfToken_success() throws Exception { verify(cs).getClusterName(); verify(threadPool).getThreadContext(); } + + @Test + public void testCreateJwtWithNegativeExpiry() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", -300L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); + } + + @Test + public void testCreateJwtWithExceededExpiry() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(true); + + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. + ArgumentCaptor longCaptor = ArgumentCaptor.forClass(Long.class); + verify(jwtVendor).createJwt(any(), any(), any(), longCaptor.capture()); + + assertThat(600L, equalTo(longCaptor.getValue())); + } + + @Test + public void testCreateJwtWithBadEncryptionKey() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(false); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); + } + + @Test + public void testCreateJwtWithBadRoles() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(null); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); + } + + @Test + public void issueApiToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(false); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } } diff --git a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java index da35226d62..9fdba2b407 100644 --- a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java @@ -31,6 +31,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.NullAuditLog; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; @@ -160,7 +161,8 @@ PrivilegesEvaluator createPrivilegesEvaluator(SecurityDynamicConfiguration implements ActionListener { + private final CountDownLatch latch = new CountDownLatch(1); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicReference exception = new AtomicReference<>(); + + @Override + public void onResponse(T result) { + response.set(result); + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + exception.set(e); + latch.countDown(); + } + + public T assertSuccess() { + waitForCompletion(); + if (exception.get() != null) { + fail("Expected success but got exception: " + exception.get()); + } + return response.get(); + } + + public Exception assertException(Class expectedExceptionClass) { + waitForCompletion(); + Exception actualException = exception.get(); + if (actualException == null) { + fail("Expected exception of type " + expectedExceptionClass.getSimpleName() + " but operation succeeded"); + } + assertThat("Exception type mismatch", actualException, instanceOf(expectedExceptionClass)); + return actualException; + } + + void waitForCompletion() { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("Test timed out waiting for response"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Test interrupted: " + e.getMessage()); + } + } + } +} diff --git a/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java new file mode 100644 index 0000000000..8e92ce3a39 --- /dev/null +++ b/src/test/java/org/opensearch/security/util/ParsingUtilsTest.java @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; +import static org.junit.Assert.assertThrows; + +public class ParsingUtilsTest { + + @Test + public void testSafeStringList() { + List emptyResult = safeStringList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + List result = safeStringList(Arrays.asList("test1", "test2"), "test_field"); + assertThat(result, is(Arrays.asList("test1", "test2"))); + + // Not a list + assertThrows(IllegalArgumentException.class, () -> safeStringList("not a list", "test_field")); + + // List with non-string + assertThrows(IllegalArgumentException.class, () -> safeStringList(Arrays.asList("test", 123), "test_field")); + } + + @Test + public void testSafeMapList() { + List> emptyResult = safeMapList(null, "test_field"); + assertThat(emptyResult, is(Collections.emptyList())); + + Map map1 = new HashMap<>(); + map1.put("key1", "value1"); + map1.put("key2", 123); + + Map map2 = new HashMap<>(); + map2.put("key3", "value3"); + map2.put("key4", true); + + List> input = Arrays.asList(map1, map2); + List> result = safeMapList(input, "test_field"); + assertThat(result, is(input)); + + // Test not a list + assertThrows(IllegalArgumentException.class, () -> safeMapList("not a list", "test_field")); + + // Test list with non-map element + assertThrows(IllegalArgumentException.class, () -> safeMapList(Arrays.asList(map1, "not a map"), "test_field")); + + List> list = safeMapList(Arrays.asList(map1, map2), "test_field"); + assertThat(list.size(), is(2)); + assertThat(list.contains(map1), is(true)); + assertThat(list.contains(map2), is(true)); + + } + +}