diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index 1f60cf92d5..bfe6e600c9 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -38,14 +38,20 @@ import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; +import org.mockito.Mockito; + import static org.hamcrest.MatcherAssert.assertThat; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isAllowed; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; @@ -280,6 +286,69 @@ public void hasAny_wildcard() throws Exception { isForbidden(missingPrivileges("cluster:whatever")) ); } + + @Test + public void apiToken_explicit_failsWithWildcard() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("*"), List.of()) + ); + // Explicit fails + assertThat( + subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), + isForbidden(missingPrivileges("cluster:whatever")) + ); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithExactMatch() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("cluster:whatever"), List.of()) + ); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever", "cluster:other")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.empty(CType.ROLES); + + SecurityDynamicConfiguration config = SecurityDynamicConfiguration.fromYaml( + "CLUSTER_ALL:\n allowed_actions:\n - \"cluster:*\"", + CType.ACTIONGROUPS + ); + + FlattenedActionGroups actionGroups = new FlattenedActionGroups(config); + ActionPrivileges subject = new ActionPrivileges(roles, actionGroups, null, Settings.EMPTY); + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("CLUSTER_ALL"), List.of()) + ); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:monitor/main"), isAllowed()); + } } /** @@ -314,9 +383,20 @@ public void positive_full() throws Exception { assertThat(result, isAllowed()); } + @Test + public void apiTokens_positive_full() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isAllowed()); + } + @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11", "index_a12")); if (covers(ctx, "index_a11", "index_a12")) { @@ -330,7 +410,7 @@ public void positive_partial() throws Exception { @Test public void positive_partial2() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -363,14 +443,26 @@ public void positive_noLocal() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("index_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } + @Test + public void apiToken_negative_noPermissions() throws Exception { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of(), List.of(new ApiToken.IndexPermission(List.of(), List.of()))) + ); + + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isForbidden()); + } + @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -382,7 +474,7 @@ public void negative_wrongAction() throws Exception { @Test public void positive_hasExplicit_full() { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(ctx, requiredActions, resolved("index_a11")); if (actionSpec.givenPrivs.contains("*")) { @@ -397,7 +489,21 @@ public void positive_hasExplicit_full() { } } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + @Test + public void apiTokens_positive_hasExplicit_full() { + String token = "blah"; + PermissionBasedPrivilegesEvaluationContext context = ctxForApiToken( + "apitoken:" + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + + PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(context, requiredActions, resolved("index_a11")); + + assertThat(result, isForbidden()); + + } + + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -522,7 +628,7 @@ public static class DataStreams { @Test public void positive_full() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); if (covers(ctx, "data_stream_a11")) { assertThat(result, isAllowed()); @@ -538,7 +644,7 @@ public void positive_full() throws Exception { @Test public void positive_partial() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx, requiredActions, @@ -569,19 +675,19 @@ public void positive_partial() throws Exception { @Test public void negative_wrongRole() throws Exception { - PrivilegesEvaluationContext ctx = ctx("other_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("other_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, requiredActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(requiredActions))); } @Test public void negative_wrongAction() throws Exception { - PrivilegesEvaluationContext ctx = ctx("test_role"); + RoleBasedPrivilegesEvaluationContext ctx = ctx("test_role"); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(ctx, otherActions, resolved("data_stream_a11")); assertThat(result, isForbidden(missingPrivileges(otherActions))); } - private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { + private boolean covers(RoleBasedPrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { return false; @@ -1039,10 +1145,15 @@ static SecurityDynamicConfiguration createRoles(int numberOfRoles, int n } } - static PrivilegesEvaluationContext ctx(String... roles) { - User user = new User("test_user"); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return ctxWithUserName("test-user", roles); + } + + static RoleBasedPrivilegesEvaluationContext ctxWithUserName(String userName, String... roles) { + User user = new User(userName); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + ApiTokenRepository mockRepository = Mockito.mock(ApiTokenRepository.class); + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, @@ -1054,10 +1165,25 @@ static PrivilegesEvaluationContext ctx(String... roles) { ); } - static PrivilegesEvaluationContext ctxByUsername(String username) { + static PermissionBasedPrivilegesEvaluationContext ctxForApiToken(String userName, Permissions permissions) { + User user = new User(userName); + user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); + return new PermissionBasedPrivilegesEvaluationContext( + user, + null, + null, + null, + null, + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), + null, + permissions + ); + } + + static RoleBasedPrivilegesEvaluationContext ctxByUsername(String username) { User user = new User(username); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java index e098a605e5..246d28d542 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java @@ -231,14 +231,14 @@ public void equals() { assertFalse(a1.equals(a1.toString())); } - private static PrivilegesEvaluationContext ctx() { + private static RoleBasedPrivilegesEvaluationContext ctx() { IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); IndexResolverReplacer indexResolverReplacer = new IndexResolverReplacer(indexNameExpressionResolver, () -> CLUSTER_STATE, null); User user = new User("test_user"); user.addAttributes(ImmutableMap.of("attrs.a11", "a11")); user.addAttributes(ImmutableMap.of("attrs.year", "year")); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of(), "indices:action/test", diff --git a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java index 1e61aa0206..6576a84fb4 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java @@ -188,13 +188,13 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { .collect(Collectors.toList()); for (final Endpoint endpoint : noSslEndpoints) { final String permission = ENDPOINTS_WITH_PERMISSIONS.get(endpoint).build(); - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(endpoint.name().toLowerCase(Locale.ROOT))); Assert.assertTrue(endpoint.name(), actionPrivileges.hasExplicitClusterPrivilege(ctx, permission).isAllowed()); assertHasNoPermissionsForRestApiAdminOnePermissionRole(endpoint, ctx); } // verify SSL endpoint with 2 actions for (final String sslAction : ImmutableSet.of(CERTS_INFO_ACTION, RELOAD_CERTS_ACTION)) { - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(sslAction)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.SSL); Assert.assertTrue( Endpoint.SSL + "/" + sslAction, @@ -203,7 +203,7 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.SSL, ctx); } // verify CONFIG endpoint with 1 action - final PrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); + final RoleBasedPrivilegesEvaluationContext ctx = ctx(restAdminApiRoleName(SECURITY_CONFIG_UPDATE)); final PermissionBuilder permissionBuilder = ENDPOINTS_WITH_PERMISSIONS.get(Endpoint.CONFIG); Assert.assertTrue( Endpoint.SSL + "/" + SECURITY_CONFIG_UPDATE, @@ -212,7 +212,10 @@ public void hasExplicitClusterPermissionPermissionForRestAdmin() { assertHasNoPermissionsForRestApiAdminOnePermissionRole(Endpoint.CONFIG, ctx); } - void assertHasNoPermissionsForRestApiAdminOnePermissionRole(final Endpoint allowEndpoint, final PrivilegesEvaluationContext ctx) { + void assertHasNoPermissionsForRestApiAdminOnePermissionRole( + final Endpoint allowEndpoint, + final RoleBasedPrivilegesEvaluationContext ctx + ) { final Collection noPermissionEndpoints = ENDPOINTS_WITH_PERMISSIONS.keySet() .stream() .filter(e -> e != allowEndpoint) @@ -250,8 +253,17 @@ static SecurityDynamicConfiguration createRolesConfig() throws IOExcepti return SecurityDynamicConfiguration.fromNode(rolesNode, CType.ROLES, 2, 0, 0); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.copyOf(roles), null, null, null, null, null, null); + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( + new User("test_user"), + ImmutableSet.copyOf(roles), + null, + null, + null, + null, + null, + null + ); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java index 2c8e6de587..65b2f30b3a 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java @@ -35,7 +35,7 @@ import org.opensearch.index.query.RangeQueryBuilder; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.search.internal.ShardSearchRequest; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.Base64Helper; @@ -55,6 +55,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; public class DlsFlsLegacyHeadersTest { static NamedXContentRegistry xContentRegistry = new NamedXContentRegistry( @@ -255,11 +256,11 @@ public void performHeaderDecoration_oldNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -277,7 +278,7 @@ public void performHeaderDecoration_actionRequest() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_2_0_0); // SearchRequest does extend ActionRequest, thus the headers must not be set @@ -296,11 +297,11 @@ public void performHeaderDecoration_newNode() throws Exception { Metadata metadata = exampleMetadata(); DlsFlsProcessedConfig dlsFlsProcessedConfig = dlsFlsProcessedConfig(exampleRolesConfig(), metadata); - Transport.Connection connection = Mockito.mock(Transport.Connection.class); + Transport.Connection connection = mock(Transport.Connection.class); Mockito.when(connection.getVersion()).thenReturn(Version.V_3_0_0); // ShardSearchRequest does not extend ActionRequest, thus the headers must be set - ShardSearchRequest request = Mockito.mock(ShardSearchRequest.class); + ShardSearchRequest request = mock(ShardSearchRequest.class); Map headerSink = new HashMap<>(); @@ -337,7 +338,7 @@ public void prepare_ccs() throws Exception { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - PrivilegesEvaluationContext ctx = new PrivilegesEvaluationContext( + RoleBasedPrivilegesEvaluationContext ctx = new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.of("test_role"), null, @@ -352,11 +353,11 @@ public void prepare_ccs() throws Exception { assertTrue(threadContext.getResponseHeaders().containsKey(ConfigConstants.OPENDISTRO_SECURITY_DLS_QUERY_HEADER)); } - static PrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { + static RoleBasedPrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - return new PrivilegesEvaluationContext( + return new RoleBasedPrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java index 97a0ddb69e..f81fe39d5d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java @@ -52,8 +52,8 @@ import org.opensearch.index.query.QueryBuilders; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -121,7 +121,7 @@ public static class IndicesAndAliases_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -518,7 +518,7 @@ public IndicesAndAliases_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -566,7 +566,7 @@ public static class IndicesAndAliases_isUnrestricted { final User user; final IndicesSpec indicesSpec; final IndexResolverReplacer.Resolved resolvedIndices; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -833,7 +833,7 @@ public IndicesRequest indices(String... strings) { return this; } }); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -874,7 +874,7 @@ public static class DataStreams_getRestriction { final User user; final IndexSpec indexSpec; final IndexAbstraction.Index index; - final PrivilegesEvaluationContext context; + final RoleBasedPrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @Test @@ -1118,7 +1118,7 @@ public DataStreams_getRestriction( this.indexSpec = indexSpec; this.user = userSpec.buildUser(); this.index = (IndexAbstraction.Index) INDEX_METADATA.getIndicesLookup().get(indexSpec.index); - this.context = new PrivilegesEvaluationContext( + this.context = new RoleBasedPrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, @@ -1146,7 +1146,9 @@ public void invalidQuery() throws Exception { @Test(expected = PrivilegesEvaluationException.class) public void invalidTemplatedQuery() throws Exception { DocumentPrivileges.DlsQuery.create("{\"invalid\": \"totally ${attr.foo}\"}", xContentRegistry) - .evaluate(new PrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null)); + .evaluate( + new RoleBasedPrivilegesEvaluationContext(new User("test_user"), ImmutableSet.of(), null, null, null, null, null, null) + ); } @Test diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java index 7f4c5bacf2..97768a6faa 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java @@ -23,7 +23,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -114,8 +114,8 @@ static FieldMasking createSubject(SecurityDynamicConfiguration roleConfi ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java index 54a32e9972..731c910fc8 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java @@ -22,7 +22,7 @@ import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.RoleBasedPrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -149,8 +149,8 @@ static FieldPrivileges createSubject(SecurityDynamicConfiguration roleCo ); } - static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( + static RoleBasedPrivilegesEvaluationContext ctx(String... roles) { + return new RoleBasedPrivilegesEvaluationContext( new User("test_user"), ImmutableSet.copyOf(roles), null, diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 67efe1ecbd..1d5091ca04 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -133,6 +133,9 @@ import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.apitokens.ApiTokenAction; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.action.apitokens.ApiTokenUpdateAction; +import org.opensearch.security.action.apitokens.TransportApiTokenUpdateAction; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -256,6 +259,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; + private volatile ApiTokenRepository ar; private volatile AdminDNs adminDns; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); @@ -645,7 +649,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, localClient, tokenManager)); + handlers.add(new ApiTokenAction(ar)); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -687,6 +691,7 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre List> actions = new ArrayList<>(1); if (!disabled && !SSLConfig.isSslOnlyMode()) { actions.add(new ActionHandler<>(ConfigUpdateAction.INSTANCE, TransportConfigUpdateAction.class)); + actions.add(new ActionHandler<>(ApiTokenUpdateAction.INSTANCE, TransportApiTokenUpdateAction.class)); // external storage does not support reload and does not provide SSL certs info if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); @@ -719,6 +724,7 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1105,6 +1111,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); tokenManager = new SecurityTokenManager(cs, threadPool, userService); + ar = new ApiTokenRepository(localClient, clusterService, tokenManager); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1120,7 +1127,8 @@ public Collection createComponents( privilegesInterceptor, cih, irr, - namedXContentRegistry.get() + namedXContentRegistry.get(), + ar ); dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); @@ -1162,7 +1170,7 @@ public Collection createComponents( configPath, compatConfig ); - dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher); + dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher, ar); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); @@ -1212,6 +1220,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(ar); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java index d8be267da3..6a81ad9f4d 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -16,15 +16,12 @@ import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.annotation.JsonIgnore; - import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; public class ApiToken implements ToXContent { public static final String NAME_FIELD = "name"; - public static final String JTI_FIELD = "jti"; public static final String CREATION_TIME_FIELD = "creation_time"; public static final String CLUSTER_PERMISSIONS_FIELD = "cluster_permissions"; public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; @@ -33,15 +30,13 @@ public class ApiToken implements ToXContent { public static final String EXPIRATION_FIELD = "expiration"; private final String name; - private final String jti; private final Instant creationTime; private final List clusterPermissions; private final List indexPermissions; private final long expiration; - public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions, Long expiration) { + public ApiToken(String name, List clusterPermissions, List indexPermissions, Long expiration) { this.creationTime = Instant.now(); - this.jti = jti; this.name = name; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; @@ -50,14 +45,12 @@ public ApiToken(String name, String jti, List clusterPermissions, List clusterPermissions, List indexPermissions, Instant creationTime, Long expiration ) { this.name = name; - this.jti = jti; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; this.creationTime = creationTime; @@ -140,7 +133,6 @@ public static IndexPermission fromXContent(XContentParser parser) throws IOExcep */ public static ApiToken fromXContent(XContentParser parser) throws IOException { String name = null; - String jti = null; List clusterPermissions = new ArrayList<>(); List indexPermissions = new ArrayList<>(); Instant creationTime = null; @@ -157,9 +149,6 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { case NAME_FIELD: name = parser.text(); break; - case JTI_FIELD: - jti = parser.text(); - break; case CREATION_TIME_FIELD: creationTime = Instant.ofEpochMilli(parser.longValue()); break; @@ -185,7 +174,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { } } - return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime, expiration); + return new ApiToken(name, clusterPermissions, indexPermissions, creationTime, expiration); } private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { @@ -224,11 +213,6 @@ public Long getExpiration() { return expiration; } - @JsonIgnore - public String getJti() { - return jti; - } - public Instant getCreationTime() { return creationTime; } @@ -241,7 +225,6 @@ public List getClusterPermissions() { public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { xContentBuilder.startObject(); xContentBuilder.field(NAME_FIELD, name); - xContentBuilder.field(JTI_FIELD, jti); xContentBuilder.field(CLUSTER_PERMISSIONS_FIELD, clusterPermissions); xContentBuilder.field(INDEX_PERMISSIONS_FIELD, indexPermissions); xContentBuilder.field(CREATION_TIME_FIELD, creationTime.toEpochMilli()); diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index e2e373812f..0b6a7b320a 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -20,17 +20,18 @@ import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import org.opensearch.client.Client; import org.opensearch.client.node.NodeClient; -import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.rest.BaseRestHandler; import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; -import org.opensearch.security.identity.SecurityTokenManager; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -47,7 +48,8 @@ import static org.opensearch.security.util.ParsingUtils.safeStringList; public class ApiTokenAction extends BaseRestHandler { - private final ApiTokenRepository apiTokenRepository; + private ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); private static final List ROUTES = addRoutesPrefix( ImmutableList.of( @@ -57,8 +59,8 @@ public class ApiTokenAction extends BaseRestHandler { ) ); - public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) { - this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); + public ApiTokenAction(ApiTokenRepository apiTokenRepository) { + this.apiTokenRepository = apiTokenRepository; } @Override @@ -133,20 +135,32 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) ); - builder.startObject(); - builder.field("Api Token: ", token); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + // Then trigger the update action + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("Api Token: ", token); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (IOException e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token creation"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token creation"); + } + }); } catch (final Exception exception) { - builder.startObject() - .field("error", "An unexpected error occurred. Please check the input and try again.") - .field("message", exception.getMessage()) - .endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } @@ -239,22 +253,46 @@ private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) validateRequestParameters(requestBody); apiTokenRepository.deleteApiToken((String) requestBody.get(NAME_FIELD)); - builder.startObject(); - builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token update"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token deletion"); + } + }); } catch (final ApiTokenException exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.NOT_FOUND, builder); + sendErrorResponse(channel, RestStatus.NOT_FOUND, exception.getMessage()); } catch (final Exception exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + BytesRestResponse response = new BytesRestResponse(status, builder); + channel.sendResponse(response); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index ce81aceb4b..b1f99bdbd6 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -13,18 +13,59 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; + +import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; public class ApiTokenRepository { private final ApiTokenIndexHandler apiTokenIndexHandler; private final SecurityTokenManager securityTokenManager; + private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); + + private final Map jtis = new ConcurrentHashMap<>(); + + void reloadApiTokensFromIndex() { + Map tokensFromIndex = apiTokenIndexHandler.getTokenMetadatas(); + jtis.keySet().removeIf(key -> !tokensFromIndex.containsKey(key)); + tokensFromIndex.forEach( + (key, apiToken) -> jtis.put(key, new Permissions(apiToken.getClusterPermissions(), apiToken.getIndexPermissions())) + ); + } + + public Permissions getApiTokenPermissionsForUser(User user) { + String name = user.getName(); + if (name.startsWith(API_TOKEN_USER_PREFIX)) { + String jti = user.getName().split(API_TOKEN_USER_PREFIX)[1]; + if (isValidToken(jti)) { + return getPermissionsForJti(jti); + } + } + return new Permissions(List.of(), List.of()); + } + + public Permissions getPermissionsForJti(String jti) { + return jtis.get(jti); + } + + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); + } + + public Map getJtis() { + return jtis; + } public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); @@ -49,14 +90,8 @@ public String createApiToken( ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); // TODO: Add validation on whether user is creating a token with a subset of their permissions - ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions); - ApiToken apiToken = new ApiToken( - name, - securityTokenManager.encryptToken(token.getCompleteToken()), - clusterPermissions, - indexPermissions, - expiration - ); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); + ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration); apiTokenIndexHandler.indexTokenMetadata(apiToken); return token.getCompleteToken(); } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java new file mode 100644 index 0000000000..c9d324c52f --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import org.opensearch.action.ActionType; + +public class ApiTokenUpdateAction extends ActionType { + + public static final ApiTokenUpdateAction INSTANCE = new ApiTokenUpdateAction(); + public static final String NAME = "cluster:admin/opendistro_security/apitoken/update"; + + protected ApiTokenUpdateAction() { + super(NAME, ApiTokenUpdateResponse::new); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java new file mode 100644 index 0000000000..429310d966 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; + +public class ApiTokenUpdateNodeResponse extends BaseNodeResponse { + public ApiTokenUpdateNodeResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateNodeResponse(DiscoveryNode node) { + super(node); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java new file mode 100644 index 0000000000..f78c0370d5 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ApiTokenUpdateRequest extends BaseNodesRequest { + + public ApiTokenUpdateRequest(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateRequest() throws IOException { + super(new String[0]); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java new file mode 100644 index 0000000000..99d94bd578 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenUpdateResponse extends BaseNodesResponse implements ToXContentObject { + + public ApiTokenUpdateResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateResponse( + final ClusterName clusterName, + List nodes, + List failures + ) { + super(clusterName, nodes, failures); + } + + @Override + public List readNodesFrom(final StreamInput in) throws IOException { + return in.readList(ApiTokenUpdateNodeResponse::new); + } + + @Override + public void writeNodesTo(final StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("ApiTokenupdate_response"); + builder.field("nodes", getNodesMap()); + builder.field("node_size", getNodes().size()); + builder.field("has_failures", hasFailures()); + builder.field("failures_size", failures().size()); + builder.endObject(); + + return builder; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/Permissions.java b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java new file mode 100644 index 0000000000..9b684cebde --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.action.apitokens; + +import java.util.Collections; +import java.util.List; + +public class Permissions { + private final List clusterPerm; + private final List indexPermission; + + public Permissions(List clusterPerm, List indexPermission) { + this.clusterPerm = clusterPerm; + this.indexPermission = indexPermission; + } + + public Permissions() { + this.clusterPerm = Collections.emptyList(); + this.indexPermission = Collections.emptyList(); + } + + public List getClusterPerm() { + return clusterPerm; + } + + public List getIndexPermission() { + return indexPermission; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java new file mode 100644 index 0000000000..c486deab71 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenRepository apiTokenRepository; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + ApiTokenRepository apiTokenRepository + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenRepository = apiTokenRepository; + this.clusterService = clusterService; + } + + public static class NodeApiTokenUpdateRequest extends TransportRequest { + ApiTokenUpdateRequest request; + + public NodeApiTokenUpdateRequest(ApiTokenUpdateRequest request) { + this.request = request; + } + + public NodeApiTokenUpdateRequest(StreamInput streamInput) throws IOException { + super(streamInput); + this.request = new ApiTokenUpdateRequest(streamInput); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + } + + @Override + protected ApiTokenUpdateNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ApiTokenUpdateNodeResponse(in); + } + + @Override + protected ApiTokenUpdateResponse newResponse( + ApiTokenUpdateRequest request, + List responses, + List failures + ) { + return new ApiTokenUpdateResponse(this.clusterService.getClusterName(), responses, failures); + } + + @Override + protected NodeApiTokenUpdateRequest newNodeRequest(ApiTokenUpdateRequest request) { + return new NodeApiTokenUpdateRequest(request); + } + + @Override + protected ApiTokenUpdateNodeResponse nodeOperation(final NodeApiTokenUpdateRequest request) { + apiTokenRepository.reloadApiTokensFromIndex(); + return new ApiTokenUpdateNodeResponse(clusterService.localNode()); + } +} diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 8bf8f63dde..9a16cd8bfd 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -584,22 +584,24 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index originalSource = "{}"; } if (securityIndicesMatcher.test(shardId.getIndexName())) { - try ( - XContentParser parser = XContentHelper.createParser( - NamedXContentRegistry.EMPTY, - THROW_UNSUPPORTED_OPERATION, - originalResult.internalSourceRef(), - XContentType.JSON - ) - ) { - Object base64 = parser.map().values().iterator().next(); - if (base64 instanceof String) { - originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); - } else { - originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + if (originalSource == null) { + try ( + XContentParser parser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + originalResult.internalSourceRef(), + XContentType.JSON + ) + ) { + Object base64 = parser.map().values().iterator().next(); + if (base64 instanceof String) { + originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); + } else { + originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + } + } catch (Exception e) { + log.error(e.toString()); } - } catch (Exception e) { - log.error(e.toString()); } try ( @@ -640,7 +642,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } } - if (!complianceConfig.shouldLogWriteMetadataOnly()) { + if (!complianceConfig.shouldLogWriteMetadataOnly() && !complianceConfig.shouldLogDiffsForWrite()) { if (securityIndicesMatcher.test(shardId.getIndexName())) { // current source, normally not null or empty try ( diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 75ce45912a..8fd3589ebe 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,16 +11,11 @@ package org.opensearch.security.authtoken.jwt; -import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; import java.text.ParseException; -import java.util.ArrayList; import java.util.Base64; import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,9 +23,7 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; -import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.authtoken.jwt.claims.JwtClaimsBuilder; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -41,7 +34,6 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.OctetSequenceKey; -import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; @@ -51,21 +43,11 @@ public class JwtVendor { private final JWK signingKey; private final JWSSigner signer; - private final LongSupplier timeProvider; - private final EncryptionDecryptionUtil encryptionDecryptionUtil; - private static final Integer MAX_EXPIRY_SECONDS = 600; - public JwtVendor(final Settings settings, final Optional timeProvider) { + public JwtVendor(Settings settings) { final Tuple tuple = createJwkFromSettings(settings); signingKey = tuple.v1(); signer = tuple.v2(); - - if (isKeyNull(settings, "encryption_key")) { - throw new IllegalArgumentException("encryption_key cannot be null"); - } else { - this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(settings.get("encryption_key")); - } - this.timeProvider = timeProvider.orElse(System::currentTimeMillis); } /* @@ -103,97 +85,11 @@ static Tuple createJwkFromSettings(final Settings settings) { } } - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long requestedExpirySeconds, - final List roles, - final List backendRoles, - final boolean includeBackendRoles - ) throws JOSEException, ParseException { - final long currentTimeMs = timeProvider.getAsLong(); - final Date now = new Date(currentTimeMs); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - final long expirySeconds = Math.min(requestedExpirySeconds, MAX_EXPIRY_SECONDS); - if (expirySeconds <= 0) { - throw new IllegalArgumentException("The expiration time should be a positive integer"); - } - final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); - claimsBuilder.expirationTime(expiryTime); - - if (roles != null) { - final String listOfRoles = String.join(",", roles); - claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); - } else { - throw new IllegalArgumentException("Roles cannot be null"); - } - - if (includeBackendRoles && backendRoles != null) { - final String listOfBackendRoles = String.join(",", backendRoles); - claimsBuilder.claim("br", listOfBackendRoles); - } - - final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = new SignedJWT(header, claimsBuilder.build()); - - // Sign the JWT so it can be serialized - signedJwt.sign(signer); - - if (logger.isDebugEnabled()) { - logger.debug( - "Created JWT: " + signedJwt.serialize() + "\n" + signedJwt.getHeader().toJSONObject() + "\n" + signedJwt.getJWTClaimsSet() - ); - } - - return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); - } - @SuppressWarnings("removal") - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long expiration, - final List clusterPermissions, - final List indexPermissions - ) throws JOSEException, ParseException, IOException { - final long currentTimeMs = timeProvider.getAsLong(); - final Date now = new Date(currentTimeMs); - - final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); - claimsBuilder.issuer(issuer); - claimsBuilder.issueTime(now); - claimsBuilder.subject(subject); - claimsBuilder.audience(audience); - claimsBuilder.notBeforeTime(now); - - final Date expiryTime = new Date(expiration); - claimsBuilder.expirationTime(expiryTime); - - if (clusterPermissions != null) { - final String listOfClusterPermissions = String.join(",", clusterPermissions); - claimsBuilder.claim("cp", encryptString(listOfClusterPermissions)); - } - - if (indexPermissions != null) { - List permissionStrings = new ArrayList<>(); - for (ApiToken.IndexPermission permission : indexPermissions) { - permissionStrings.add(permission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString()); - } - final String listOfIndexPermissions = String.join(",", permissionStrings); - claimsBuilder.claim("ip", encryptString(listOfIndexPermissions)); - } + public ExpiringBearerAuthToken createJwt(JwtClaimsBuilder claimsBuilder, String subject, Date expiryTime, Long expirySeconds) + throws JOSEException, ParseException { final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); - final SignedJWT signedJwt = AccessController.doPrivileged( (PrivilegedAction) () -> new SignedJWT(header, claimsBuilder.build()) ); @@ -207,16 +103,6 @@ public ExpiringBearerAuthToken createJwt( ); } - return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime); - } - - /* Returns the encrypted string based on encryption settings */ - public String encryptString(final String input) { - return encryptionDecryptionUtil.encrypt(input); - } - - /* Returns the decrypted string based on encryption settings */ - public String decryptString(final String input) { - return encryptionDecryptionUtil.decrypt(input); + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java new file mode 100644 index 0000000000..ebb5552045 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/ApiJwtClaimsBuilder.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +public class ApiJwtClaimsBuilder extends JwtClaimsBuilder { + + public ApiJwtClaimsBuilder() { + super(); + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java new file mode 100644 index 0000000000..2112606b54 --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/JwtClaimsBuilder.java @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +import java.util.Date; + +import com.nimbusds.jwt.JWTClaimsSet; + +public class JwtClaimsBuilder { + private final JWTClaimsSet.Builder builder; + + public JwtClaimsBuilder() { + this.builder = new JWTClaimsSet.Builder(); + } + + public JwtClaimsBuilder issueTime(Date issueTime) { + builder.issueTime(issueTime); + return this; + } + + public JwtClaimsBuilder notBeforeTime(Date notBeforeTime) { + builder.notBeforeTime(notBeforeTime); + return this; + } + + public JwtClaimsBuilder subject(String subject) { + builder.subject(subject); + return this; + } + + public JwtClaimsBuilder issuer(String issuer) { + builder.issuer(issuer); + return this; + } + + public JwtClaimsBuilder audience(String audience) { + builder.audience(audience); + return this; + } + + public JwtClaimsBuilder issuedAt(Date issuedAt) { + builder.issueTime(issuedAt); + return this; + } + + public JwtClaimsBuilder expirationTime(Date expirationTime) { + builder.expirationTime(expirationTime); + return this; + } + + public JwtClaimsBuilder addCustomClaim(String claimName, String value) { + builder.claim(claimName, value); + return this; + } + + public JWTClaimsSet build() { + return builder.build(); + } + +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java new file mode 100644 index 0000000000..22044a165d --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/claims/OBOJwtClaimsBuilder.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.authtoken.jwt.claims; + +import java.util.List; + +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; + +public class OBOJwtClaimsBuilder extends JwtClaimsBuilder { + private final EncryptionDecryptionUtil encryptionDecryptionUtil; + + public OBOJwtClaimsBuilder(String encryptionKey) { + super(); + this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + } + + public OBOJwtClaimsBuilder addRoles(List roles) { + final String listOfRoles = String.join(",", roles); + this.addCustomClaim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); + return this; + } + + public OBOJwtClaimsBuilder addBackendRoles(Boolean includeBackendRoles, List backendRoles) { + if (includeBackendRoles && backendRoles != null) { + final String listOfBackendRoles = String.join(",", backendRoles); + this.addCustomClaim("br", listOfBackendRoles); + } + return this; + } +} diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java new file mode 100644 index 0000000000..0a8e3466d7 --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,224 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.http; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + + public Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final Boolean apiTokenEnabled; + private final String clusterName; + public static final String API_TOKEN_USER_PREFIX = "apitoken:"; + private final ApiTokenRepository apiTokenRepository; + + @SuppressWarnings("removal") + public ApiTokenAuthenticator(Settings settings, String clusterName, ApiTokenRepository apiTokenRepository) { + String apiTokenEnabledSetting = settings.get("enabled", "true"); + apiTokenEnabled = Boolean.parseBoolean(apiTokenEnabledSetting); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); + } + }); + this.clusterName = clusterName; + this.apiTokenRepository = apiTokenRepository; + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find api token authenticator signing_key"); + } + + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); + } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + return jwtParserBuilder; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) + throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request, context); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + final String subject = claims.getSubject(); + if (subject == null) { + log.error("Api token does not have a subject"); + return null; + } + + // TODO: handle revocation different from deletion? + if (!apiTokenRepository.isValidToken(subject)) { + log.error("Api token is not allowlisted"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this api token does not match the current cluster identifier"); + return null; + } + + return new AuthCredentials(API_TOKEN_USER_PREFIX + subject, List.of(), "").markComplete(); + + } catch (WeakKeyException e) { + log.error("Cannot authenticate api token because of ", e); + return null; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired api token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(SecurityRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "apitoken_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index ca5a17b6f7..f726a8134b 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,10 +11,12 @@ package org.opensearch.security.identity; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.Date; import java.util.Set; +import java.util.function.LongSupplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,9 +30,10 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; -import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.ConfigConstants; @@ -41,6 +44,8 @@ import joptsimple.internal.Strings; import org.greenrobot.eventbus.Subscribe; +import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; + /** * This class is the Security Plugin's implementation of the TokenManager used by all Identity Plugins. * It handles the issuance of both Service Account Tokens and On Behalf Of tokens. @@ -52,9 +57,11 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; - private JwtVendor oboJwtVendor = null; - private JwtVendor apiTokenJwtVendor = null; + private Settings oboSettings = null; + private Settings apiTokenSettings = null; private ConfigModel configModel = null; + private final LongSupplier timeProvider = System::currentTimeMillis; + private static final Integer OBO_MAX_EXPIRY_SECONDS = 600; public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { this.cs = cs; @@ -69,22 +76,22 @@ public void onConfigModelChanged(final ConfigModel configModel) { @Subscribe public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { - final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); - final Boolean oboEnabled = oboSettings.getAsBoolean("enabled", false); + final Settings oboSettingsFromDcm = dcm.getDynamicOnBehalfOfSettings(); + final Boolean oboEnabled = oboSettingsFromDcm.getAsBoolean("enabled", false); if (oboEnabled) { - oboJwtVendor = createJwtVendor(oboSettings); + oboSettings = oboSettingsFromDcm; } - final Settings apiTokenSettings = dcm.getDynamicApiTokenSettings(); - final Boolean apiTokenEnabled = apiTokenSettings.getAsBoolean("enabled", false); + final Settings apiTokenSettingsFromDcm = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettingsFromDcm.getAsBoolean("enabled", false); if (apiTokenEnabled) { - apiTokenJwtVendor = createJwtVendor(apiTokenSettings); + apiTokenSettings = apiTokenSettingsFromDcm; } } /** For testing */ JwtVendor createJwtVendor(final Settings settings) { try { - return new JwtVendor(settings, Optional.empty()); + return new JwtVendor(settings); } catch (final Exception ex) { logger.error("Unable to create the JwtVendor instance", ex); return null; @@ -92,11 +99,11 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return oboJwtVendor != null && configModel != null; + return oboSettings != null && configModel != null; } public boolean issueApiTokenAllowed() { - return apiTokenJwtVendor != null && configModel != null; + return apiTokenSettings != null && configModel != null; } @Override @@ -125,46 +132,74 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final long expirySeconds = Math.min(claims.getExpiration(), OBO_MAX_EXPIRY_SECONDS); + if (expirySeconds <= 0) { + throw new IllegalArgumentException("The expiration time should be a positive integer"); + } + if (mappedRoles == null) { + throw new IllegalArgumentException("Roles cannot be null"); + } + if (isKeyNull(oboSettings, "encryption_key")) { + throw new IllegalArgumentException("encryption_key cannot be null"); + } + + final OBOJwtClaimsBuilder claimsBuilder = new OBOJwtClaimsBuilder(oboSettings.get("encryption_key")); + + // Add obo claims + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(user.getName()); + claimsBuilder.audience(claims.getAudience()); + claimsBuilder.notBeforeTime(now); + claimsBuilder.addBackendRoles(false, new ArrayList<>(user.getRoles())); + claimsBuilder.addRoles(new ArrayList<>(mappedRoles)); + + final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); + claimsBuilder.expirationTime(expiryTime); + try { - return oboJwtVendor.createJwt( - cs.getClusterName().value(), - user.getName(), - claims.getAudience(), - claims.getExpiration(), - new ArrayList<>(mappedRoles), - new ArrayList<>(user.getRoles()), - false - ); + return createJwtVendor(oboSettings).createJwt(claimsBuilder, user.getName(), expiryTime, expirySeconds); } catch (final Exception ex) { logger.error("Error creating OnBehalfOfToken for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate OnBehalfOfToken"); } } - public ExpiringBearerAuthToken issueApiToken( - final String name, - final Long expiration, - final List clusterPermissions, - final List indexPermissions - ) { + public ExpiringBearerAuthToken issueApiToken(final String name, final Long expiration) { + if (!issueApiTokenAllowed()) { + throw new OpenSearchSecurityException("Api token generation is not enabled."); + } final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final ApiJwtClaimsBuilder claimsBuilder = new ApiJwtClaimsBuilder(); + claimsBuilder.issuer(cs.getClusterName().value()); + claimsBuilder.issueTime(now); + claimsBuilder.subject(name); + claimsBuilder.audience(name); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + try { - return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration, clusterPermissions, indexPermissions); + return createJwtVendor(apiTokenSettings).createJwt( + claimsBuilder, + name, + expiryTime, + Duration.between(Instant.now(), expiryTime.toInstant()).getSeconds() + ); } catch (final Exception ex) { logger.error("Error creating Api Token for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate Api Token"); } } - public String encryptToken(final String token) { - return apiTokenJwtVendor.encryptString(token); - } - - public String decryptString(final String input) { - return apiTokenJwtVendor.decryptString(input); - } - @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index eb560ed901..dcb6cded2d 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -13,7 +13,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -35,6 +37,8 @@ import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -141,11 +145,29 @@ public ActionPrivileges( } public PrivilegesEvaluatorResponse hasClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), false); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationContext context, Set actions) { - return cluster.providesAnyPrivilege(context, actions, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesAnyPrivilege((RoleBasedPrivilegesEvaluationContext) context, actions, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, actions, false); + } else { + // Not supported + if (actions.size() == 1) { + return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); + } else { + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } + } } /** @@ -159,7 +181,14 @@ public PrivilegesEvaluatorResponse hasAnyClusterPrivilege(PrivilegesEvaluationCo * Otherwise, allowed will be false and missingPrivileges will contain the name of the given action. */ public PrivilegesEvaluatorResponse hasExplicitClusterPrivilege(PrivilegesEvaluationContext context, String action) { - return cluster.providesExplicitPrivilege(context, action, context.getMappedRoles()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + return cluster.providesExplicitPrivilege((RoleBasedPrivilegesEvaluationContext) context, action, context.getMappedRoles()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return cluster.apiTokenProvidesClusterPrivilege((PermissionBasedPrivilegesEvaluationContext) context, Set.of(action), true); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient(action); + } } /** @@ -177,44 +206,64 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); - if (response != null) { - return response; - } + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + PrivilegesEvaluatorResponse response = this.index.providesWildcardPrivilege(context, actions); + if (response != null) { + return response; + } - if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { - // This is necessary for requests which operate on remote indices. - // Access control for the remote indices will be performed on the remote cluster. - log.debug("No local indices; grant the request"); - return PrivilegesEvaluatorResponse.ok(); - } + if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { + // This is necessary for requests which operate on remote indices. + // Access control for the remote indices will be performed on the remote cluster. + log.debug("No local indices; grant the request"); + return PrivilegesEvaluatorResponse.ok(); + } - // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart - // what's the action and what's the index in the generic parameters of CheckTable. - CheckTable checkTable = CheckTable.create( - resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), - actions - ); + // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart + // what's the action and what's the index in the generic parameters of CheckTable. + CheckTable checkTable = CheckTable.create( + resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), + actions + ); - StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); - PrivilegesEvaluatorResponse resultFromStatefulIndex = null; + StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); + PrivilegesEvaluatorResponse resultFromStatefulIndex = null; - Map indexMetadata = this.indexMetadataSupplier.get(); + Map indexMetadata = this.indexMetadataSupplier.get(); - if (statefulIndex != null) { - resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); + if (statefulIndex != null) { + resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable, indexMetadata); - if (resultFromStatefulIndex != null) { - // If we get a result from statefulIndex, we are done. - return resultFromStatefulIndex; + if (resultFromStatefulIndex != null) { + // If we get a result from statefulIndex, we are done. + return resultFromStatefulIndex; + } + + // Otherwise, we need to carry on checking privileges using the non-stateful object. + // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. + // We can carry on using this as an intermediate result and further complete checkTable below. } + return this.index.providesPrivilege( + (RoleBasedPrivilegesEvaluationContext) context, + actions, + resolvedIndices, + checkTable, + indexMetadata + ); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + Map indexMetadata = this.indexMetadataSupplier.get(); + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + indexMetadata, + false + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("No explicit privileges have been provided for the referenced indices."); - // Otherwise, we need to carry on checking privileges using the non-stateful object. - // Note: statefulIndex.hasPermission() modifies as a side effect the checkTable. - // We can carry on using this as an intermediate result and further complete checkTable below. } - - return this.index.providesPrivilege(context, actions, resolvedIndices, checkTable, indexMetadata); } /** @@ -229,8 +278,21 @@ public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( Set actions, IndexResolverReplacer.Resolved resolvedIndices ) { - CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); - return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + if (context instanceof RoleBasedPrivilegesEvaluationContext) { + CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); + return this.index.providesExplicitPrivilege(context, actions, resolvedIndices, checkTable, this.indexMetadataSupplier.get()); + } else if (context instanceof PermissionBasedPrivilegesEvaluationContext) { + return this.index.apiTokenProvidesIndexPrivilege( + (PermissionBasedPrivilegesEvaluationContext) context, + resolvedIndices, + actions, + this.indexMetadataSupplier.get(), + true + ); + } else { + // Not supported + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } } /** @@ -322,6 +384,8 @@ static class ClusterPrivileges { private final ImmutableSet wellKnownClusterActions; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed cluster privileges based on the given parameters. *

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

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

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

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

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

+ * As this class is request-scoped, it is only used by a single thread. Thus, no thread synchronization mechanisms + * are necessary. + */ +public class RoleBasedPrivilegesEvaluationContext extends PrivilegesEvaluationContext { + private ImmutableSet mappedRoles; + + public RoleBasedPrivilegesEvaluationContext( + User user, + ImmutableSet mappedRoles, + String action, + ActionRequest request, + Task task, + IndexResolverReplacer indexResolverReplacer, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier clusterStateSupplier + ) { + super(user, action, request, task, indexResolverReplacer, indexNameExpressionResolver, clusterStateSupplier); + this.mappedRoles = mappedRoles; + } + + @Override + public ImmutableSet getMappedRoles() { + return mappedRoles; + } + + /** + * Note: Ideally, mappedRoles would be an unmodifiable attribute. PrivilegesEvaluator however contains logic + * related to OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION which first validates roles and afterwards modifies + * them again. Thus, we need to be able to set this attribute. + * + * However, this method should be only used for this one particular phase. Normally, all roles should be determined + * upfront and stay constant during the whole privilege evaluation process. + */ + @Override + void setMappedRoles(ImmutableSet mappedRoles) { + this.mappedRoles = mappedRoles; + } + + @Override + public String toString() { + return "RoleBasedPrivilegesEvaluationContext{" + + "user=" + + getUser() + + ", action='" + + getAction() + + '\'' + + ", request=" + + getRequest() + + ", resolvedRequest=" + + getResolvedRequest() + + ", mappedRoles=" + + mappedRoles + + '}'; + } +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 10402f7b56..1f86f459c8 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -43,6 +43,7 @@ import org.opensearch.client.Client; import org.opensearch.common.settings.Settings; import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.config.AuditConfig; import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; @@ -127,6 +128,7 @@ public final static SecurityDynamicConfiguration addStatics(SecurityDynam private final Path configPath; private final InternalAuthenticationBackend iab; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; SecurityDynamicConfiguration config; @@ -137,7 +139,8 @@ public DynamicConfigFactory( Client client, ThreadPool threadPool, ClusterInfoHolder cih, - PasswordHasher passwordHasher + PasswordHasher passwordHasher, + ApiTokenRepository apiTokenRepository ) { super(); this.cr = cr; @@ -145,6 +148,7 @@ public DynamicConfigFactory( this.configPath = configPath; this.cih = cih; this.iab = new InternalAuthenticationBackend(passwordHasher); + this.apiTokenRepository = apiTokenRepository; if (opensearchSettings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES, true)) { try { @@ -269,7 +273,7 @@ public void onChange(ConfigurationMap typeToConfig) { ); // rebuild v7 Models - dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); + dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih, apiTokenRepository); ium = new InternalUsersModelV7(internalusers, roles, rolesmapping); cm = new ConfigModelV7(roles, rolesmapping, actionGroups, tenants, dcm, opensearchSettings); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 9c90e2341f..55271e960b 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -49,6 +49,7 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auth.AuthDomain; import org.opensearch.security.auth.AuthFailureListener; import org.opensearch.security.auth.AuthenticationBackend; @@ -59,6 +60,7 @@ import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.v7.ConfigV7; @@ -85,13 +87,15 @@ public class DynamicConfigModelV7 extends DynamicConfigModel { private List> ipClientBlockRegistries; private Multimap> authBackendClientBlockRegistries; private final ClusterInfoHolder cih; + private final ApiTokenRepository apiTokenRepository; public DynamicConfigModelV7( ConfigV7 config, Settings opensearchSettings, Path configPath, InternalAuthenticationBackend iab, - ClusterInfoHolder cih + ClusterInfoHolder cih, + ApiTokenRepository apiTokenRepository ) { super(); this.config = config; @@ -99,6 +103,7 @@ public DynamicConfigModelV7( this.configPath = configPath; this.iab = iab; this.cih = cih; + this.apiTokenRepository = apiTokenRepository; buildAAA(); } @@ -377,6 +382,23 @@ private void buildAAA() { } } + /* + * If the Api token authentication is configured: + * Add the ApiToken authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when ApiToken authentication failed + * order: -2 - prioritize the Api token authentication when it gets enabled + */ + Settings apiTokenSettings = getDynamicApiTokenSettings(); + if (!isKeyNull(apiTokenSettings, "signing_key")) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName(), apiTokenRepository), + false, + -2 + ); + restAuthDomains0.add(_ad); + } + /* * If the OnBehalfOf (OBO) authentication is configured: * Add the OBO authbackend in to the auth domains diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 6555c0838d..d960a9e9bd 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -503,8 +503,6 @@ public static class ApiTokenSettings { private Boolean enabled = Boolean.FALSE; @JsonProperty("signing_key") private String signingKey; - @JsonProperty("encryption_key") - private String encryptionKey; @JsonIgnore public String configAsJson() { @@ -519,8 +517,8 @@ public Boolean getEnabled() { return enabled; } - public void setEnabled(Boolean oboEnabled) { - this.enabled = oboEnabled; + public void setEnabled(Boolean apiTokensEnabled) { + this.enabled = apiTokensEnabled; } public String getSigningKey() { @@ -531,17 +529,9 @@ public void setSigningKey(String signingKey) { this.signingKey = signingKey; } - public String getEncryptionKey() { - return encryptionKey; - } - - public void setEncryptionKey(String encryptionKey) { - this.encryptionKey = encryptionKey; - } - @Override public String toString() { - return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + "]"; } } diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 4683075f1d..32a70a468f 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -68,6 +68,10 @@ public static OpenSearchException invalidUsageOfOBOTokenException() { return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); } + public static OpenSearchException invalidUsageOfApiTokenException() { + return new OpenSearchException("Api Tokens are not allowed to be used for accessing this endpoint."); + } + public static OpenSearchException createJwkCreationException() { return new OpenSearchException("An error occurred during the creation of Jwk."); } diff --git a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java index 3884bf75fe..caccb91407 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -20,6 +20,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String API_TOKEN_SUFFIX = "api/apitokens"; public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { if (suffix == null) { @@ -28,6 +29,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques switch (suffix) { case ON_BEHALF_OF_SUFFIX: return request.method() == POST; + case API_TOKEN_SUFFIX: + // Don't want to allow any api token access + return true; case ACCOUNT_SUFFIX: return request.method() == PUT; default: diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 483fe7c9d7..e7193710e1 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -23,10 +23,11 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; public class ApiTokenActionTest { - private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null); + private final ApiTokenAction apiTokenAction = new ApiTokenAction(mock(ApiTokenRepository.class)); @Test public void testCreateIndexPermission() { diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..c24c632fa0 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,197 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.action.apitokens; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; + +import org.apache.logging.log4j.Logger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.security.user.AuthCredentials; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenAuthenticatorTest { + + private ApiTokenAuthenticator authenticator; + @Mock + private Logger log; + @Mock + private ApiTokenRepository apiTokenRepository; + + private ThreadContext threadcontext; + private final String signingKey = Base64.getEncoder() + .encodeToString("jwt signing key long enough for secure api token authentication testing".getBytes(StandardCharsets.UTF_8)); + private final String tokenName = "test-token"; + + @Before + public void setUp() { + Settings settings = Settings.builder().put("enabled", "true").put("signing_key", signingKey).build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + when(log.isDebugEnabled()).thenReturn(true); + threadcontext = new ThreadContext(Settings.EMPTY); + } + + @Test + public void testAuthenticationFailsWhenJtiNotInCache() { + String testJti = "test-jti-not-in-cache"; + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + AuthCredentials credentials = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is not in allowlist cache", credentials); + } + + @Test + public void testExtractCredentialsPassWhenJtiInCache() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNotNull("Should not be null when JTI is in allowlist cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().minus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is expired", ac); + verify(log).debug(eq("Invalid or expired api token."), any(ExpiredJwtException.class)); + + } + + @Test + public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { + String token = Jwts.builder() + .setIssuer("not-opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + when(apiTokenRepository.isValidToken(tokenName)).thenReturn(true); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when issuer does not match cluster", ac); + verify(log).error(eq("The issuer of this api token does not match the current cluster identifier")); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + token); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is being used to access restricted endpoint", ac); + verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); + } + + @Test + public void testAuthenticatorNotEnabled() { + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject(tokenName) + .setAudience(tokenName) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + SecurityRequest request = mock(SecurityRequest.class); + + Settings settings = Settings.builder() + .put("enabled", "false") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster", apiTokenRepository); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when api tokens auth is not enabled", ac); + verify(log).error(eq("Api token authentication is disabled")); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java index 7e03c14851..9b3b8638e2 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -192,7 +192,6 @@ public void testIndexTokenStoresTokenPayload() { ); ApiToken token = new ApiToken( "test-token-description", - "test-token-jti", clusterPermissions, indexPermissions, Instant.now(), @@ -231,7 +230,6 @@ public void testIndexTokenStoresTokenPayload() { String source = capturedRequest.source().utf8ToString(); assertThat(source, containsString("test-token-description")); assertThat(source, containsString("cluster:admin/something")); - assertThat(source, containsString("test-token-jti")); assertThat(source, containsString("test-index-*")); } @@ -245,7 +243,6 @@ public void testGetTokenPayloads() throws IOException { // First token ApiToken token1 = new ApiToken( "token1-description", - "token1-jti", Arrays.asList("cluster:admin/something"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index1-*"), @@ -258,7 +255,6 @@ public void testGetTokenPayloads() throws IOException { // Second token ApiToken token2 = new ApiToken( "token2-description", - "token2-jti", Arrays.asList("cluster:admin/other"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index2-*"), diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java index 03a2e2c30e..89f8b950cd 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -11,6 +11,7 @@ package org.opensearch.security.action.apitokens; +import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -18,15 +19,22 @@ import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.opensearch.index.IndexNotFoundException; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.user.User; import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.doThrow; @@ -34,13 +42,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ApiTokenRepositoryTest { @Mock private SecurityTokenManager securityTokenManager; - @Mock private ApiTokenIndexHandler apiTokenIndexHandler; - private ApiTokenRepository repository; @Before @@ -59,10 +66,33 @@ public void testDeleteApiToken() throws ApiTokenException { verify(apiTokenIndexHandler).deleteToken(tokenName); } + @Test + public void testGetApiTokenPermissionsForUser() throws ApiTokenException { + User derek = new User("derek"); + User apiTokenNotExists = new User("apitoken:notexists"); + User apiTokenExists = new User("apitoken:exists"); + repository.getJtis() + .put("exists", new Permissions(List.of("cluster_all"), List.of(new ApiToken.IndexPermission(List.of("*"), List.of("*"))))); + + Permissions permissionsForDerek = repository.getApiTokenPermissionsForUser(derek); + assertEquals(List.of(), permissionsForDerek.getClusterPerm()); + assertEquals(List.of(), permissionsForDerek.getIndexPermission()); + + Permissions permissionsForApiTokenNotExists = repository.getApiTokenPermissionsForUser(apiTokenNotExists); + assertEquals(List.of(), permissionsForApiTokenNotExists.getClusterPerm()); + assertEquals(List.of(), permissionsForApiTokenNotExists.getIndexPermission()); + + Permissions permissionsForApiTokenExists = repository.getApiTokenPermissionsForUser(apiTokenExists); + assertEquals(List.of("cluster_all"), permissionsForApiTokenExists.getClusterPerm()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getAllowedActions()); + assertEquals(List.of("*"), permissionsForApiTokenExists.getIndexPermission().getFirst().getIndexPatterns()); + + } + @Test public void testGetApiTokens() throws IndexNotFoundException { Map expectedTokens = new HashMap<>(); - expectedTokens.put("token1", new ApiToken("token1", "token1-jti", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); + expectedTokens.put("token1", new ApiToken("token1", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(expectedTokens); Map result = repository.getApiTokens(); @@ -81,23 +111,20 @@ public void testCreateApiToken() { Long expiration = 3600L; String completeToken = "complete-token"; - String encryptedToken = "encrypted-token"; ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); when(bearerToken.getCompleteToken()).thenReturn(completeToken); - when(securityTokenManager.issueApiToken(any(), any(), any(), any())).thenReturn(bearerToken); - when(securityTokenManager.encryptToken(completeToken)).thenReturn(encryptedToken); + when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); - verify(securityTokenManager).issueApiToken(any(), any(), any(), any()); - verify(securityTokenManager).encryptToken(completeToken); + verify(securityTokenManager).issueApiToken(any(), any()); verify(apiTokenIndexHandler).indexTokenMetadata( argThat( token -> token.getName().equals(tokenName) - && token.getJti().equals(encryptedToken) && token.getClusterPermissions().equals(clusterPermissions) && token.getIndexPermissions().equals(indexPermissions) + && token.getExpiration().equals(expiration) ) ); assertThat(result, equalTo(completeToken)); @@ -118,4 +145,40 @@ public void testDeleteApiTokenThrowsApiTokenException() throws ApiTokenException repository.deleteApiToken(tokenName); } + + @Test + public void testJtisOperations() { + String jti = "testJti"; + Permissions permissions = new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of()))); + + repository.getJtis().put(jti, permissions); + assertEquals("Should retrieve correct permissions", permissions, repository.getJtis().get(jti)); + + repository.getJtis().remove(jti); + assertNull("Should return null after removal", repository.getJtis().get(jti)); + } + + @Test + public void testClearJtis() { + repository.getJtis().put("testJti", new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + repository.reloadApiTokensFromIndex(); + + assertTrue("Jtis should be empty after clear", repository.getJtis().isEmpty()); + } + + @Test + public void testReloadApiTokensFromIndexAndParse() throws IOException { + when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(Map.of("test", new ApiToken("test", List.of("cluster:monitor"), List.of(), Long.MAX_VALUE))); + + // Execute the reload + repository.reloadApiTokensFromIndex(); + + // Verify the cache was updated + assertFalse("Jtis should not be empty after reload", repository.getJtis().isEmpty()); + assertEquals("Should have one JTI entry", 1, repository.getJtis().size()); + assertTrue("Should contain testJti", repository.getJtis().containsKey("test")); + // Verify extraction works + assertEquals("Should have one cluster action", List.of("cluster:monitor"), repository.getJtis().get("test").getClusterPerm()); + assertEquals("Should have no index actions", List.of(), repository.getJtis().get("test").getIndexPermission()); + } } diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index e0026155de..2ab7b9da8e 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -27,6 +27,17 @@ public class AuthTokenUtilsTest { + @Test + public void testIsAccessToRestrictedEndpointsForApiToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/apitokens") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + @Test public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 48aae6f9b8..6112f2794f 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -11,11 +11,9 @@ package org.opensearch.security.authtoken.jwt; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; -import java.util.Optional; import java.util.function.LongSupplier; import com.google.common.io.BaseEncoding; @@ -31,26 +29,19 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; -import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.xcontent.DeprecationHandler; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.authtoken.jwt.claims.ApiJwtClaimsBuilder; +import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; import org.opensearch.security.support.ConfigConstants; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jwt.SignedJWT; -import joptsimple.internal.Strings; import org.mockito.ArgumentCaptor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; import static org.junit.Assert.assertThrows; @@ -75,7 +66,7 @@ public void testCreateJwkFromSettings() { final Tuple jwk = JwtVendor.createJwkFromSettings(settings); assertThat(jwk.v1().getAlgorithm().getName(), is("HS512")); assertThat(jwk.v1().getKeyUse().toString(), is("sig")); - Assert.assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); + assertTrue(jwk.v1().toOctetSequenceKey().getKeyValue().decodeToString().startsWith(signingKey)); } @Test @@ -109,8 +100,21 @@ public void testCreateJwtWithRoles() throws Exception { String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor OBOJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(false, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -146,8 +150,21 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) // CS-ENFORCE-SINGLE .build(); - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + final ExpiringBearerAuthToken authToken = OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -163,87 +180,6 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("er").toString()), equalTo(expectedRoles)); } - @Test - public void testCreateJwtWithNegativeExpiry() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("admin"); - Integer expirySeconds = -300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); - } - - @Test - public void testCreateJwtWithExceededExpiry() throws Exception { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = List.of("IT", "HR"); - List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 900_000; - LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. - assertThat(authToken.getExpiresInSeconds(), not(equalTo(expirySeconds))); - assertThat(authToken.getExpiresInSeconds(), equalTo(600L)); - } - - @Test - public void testCreateJwtWithBadEncryptionKey() { - final String issuer = "cluster_0"; - final String subject = "admin"; - final String audience = "audience_0"; - final List roles = List.of("admin"); - final Integer expirySeconds = 300; - - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - new JwtVendor(settings, Optional.empty()).createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); - } - - @Test - public void testCreateJwtWithBadRoles() { - String issuer = "cluster_0"; - String subject = "admin"; - String audience = "audience_0"; - List roles = null; - Integer expirySeconds = 300; - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); - - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); - } - @Test public void testCreateJwtLogsCorrectly() throws Exception { mockAppender = mock(Appender.class); @@ -264,11 +200,22 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String audience = "audience_0"; final List roles = List.of("IT", "HR"); final List backendRoles = List.of("Sales", "Support"); - final int expirySeconds = 300; - - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + int expirySeconds = 300; - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + final JwtVendor OBOJwtVendor = new JwtVendor(settings); + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + OBOJwtVendor.createJwt( + new OBOJwtClaimsBuilder(claimsEncryptionKey).addRoles(roles) + .addBackendRoles(true, backendRoles) + .issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds + ); verify(mockAppender, times(1)).append(logEventCaptor.capture()); @@ -281,112 +228,45 @@ public void testCreateJwtLogsCorrectly() throws Exception { } @Test - public void testCreateJwtForApiTokenSuccess() throws Exception { - final String issuer = "cluster_0"; - final String subject = "test-token"; - final String audience = "test-token"; - final List clusterPermissions = List.of("cluster:admin/*"); - ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission(List.of("*"), List.of("read")); - final List indexPermissions = List.of(indexPermission); - final String expectedClusterPermissions = "cluster:admin/*"; - final String expectedIndexPermissions = indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) - .toString(); + public void testCreateApiTokenJwtSuccess() throws Exception { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + int expirySeconds = 300; + // 2023 oct 4, 10:00:00 AM GMT + LongSupplier currentTime = () -> 1696413600000L; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).build(); - LongSupplier currentTime = () -> (long) 100; - String claimsEncryptionKey = "1234567890123456"; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt( - issuer, - subject, - audience, - Long.MAX_VALUE, - clusterPermissions, - indexPermissions + Date expiryTime = new Date(currentTime.getAsLong() + expirySeconds * 1000); + + JwtVendor apiTokenJwtVendor = new JwtVendor(settings); + final ExpiringBearerAuthToken authToken = apiTokenJwtVendor.createJwt( + new ApiJwtClaimsBuilder().issuer(issuer) + .subject(subject) + .audience(audience) + .expirationTime(expiryTime) + .issueTime(new Date(currentTime.getAsLong())), + subject.toString(), + expiryTime, + (long) expirySeconds ); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo(issuer)); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo(subject)); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[" + audience + "]")); - assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iat"), is(notNullValue())); - // Allow for millisecond to second conversion flexibility - assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime() / 1000, equalTo(Long.MAX_VALUE / 1000)); - - EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); - assertThat( - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), - equalTo(expectedClusterPermissions) - ); - assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); - - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) - ); - ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); - - // Index permission deserialization works as expected - assertThat(indexPermission1.getIndexPatterns(), equalTo(indexPermission.getIndexPatterns())); - assertThat(indexPermission1.getAllowedActions(), equalTo(indexPermission.getAllowedActions())); - } - - @Test - public void testEncryptJwtCorrectly() { - String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); - String token = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJkZXJlayI6ImlzIGF3ZXNvbWUifQ.aPp9mSaBRBUzMJ8V_MYWUs8UoGYnJDNVriu3B9MRJpPNZtOhnIfATE0Ghmms2bGRNw9rmyRn1VIDQRmxSOTu3w"; - String expectedEncryptedToken = - "k3JQNRXR57Y4V4W1LNkpEP7FTJZos7fySJDJDGuBQXe7pi9aiEIGJ7JqjezssGRZ1AZGD/QTPQ0jjaV+rEICxBO9oyfTYWIoDdnAg5LijqPAzaULp48hi+/dqXXAAhi1zIlCSjqTDoZMTyjFxq4aRlPLjjQFuVxR3gIDMNnAUnvmFu5xh5AiVeKa1dwGy5X34Ou2i9pnQzmEDJDnf6mh7w2ODkDThJGh8JUlsUlfZEq6NwVN1XNyOr2IhPd3IZYUMgN3vWHyfjs6uwQNyHKHHcxIj4P8bJXLIGxJy3+LV5Y="; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - LongSupplier currentTime = () -> (long) 100; - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - assertThat(jwtVendor.encryptString(token), equalTo(expectedEncryptedToken)); - } - - @Test - public void testEncryptDecryptClusterIndexPermissionsCorrectly() throws IOException { - String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); - String clusterPermissions = "cluster:admin/*,cluster:*"; - String encryptedClusterPermissions = "P+KGUkpANJHzHGKVSqJhIyHOKS+JCLOanxCOBWSgZNk="; - // "{\"index_pattern\":[\"*\"],\"allowed_actions\":[\"read\"]},{\"index_pattern\":[\".*\"],\"allowed_actions\":[\"write\"]}" - String indexPermissions = Strings.join( - List.of( - new ApiToken.IndexPermission(List.of("*"), List.of("read")).toXContent( - XContentFactory.jsonBuilder(), - ToXContent.EMPTY_PARAMS - ).toString(), - new ApiToken.IndexPermission(List.of(".*"), List.of("write")).toXContent( - XContentFactory.jsonBuilder(), - ToXContent.EMPTY_PARAMS - ).toString() - ), - "," - ); - String encryptedIndexPermissions = - "Y9ssHcl6spHC2/zy+L1P0y8e2+T+jGgXcP02DWGeTMk/3KiI4Ik0Df7oXMf9l/Ba0emk9LClnHsJi8iFwRh7ii1Pxb3CTHS/d+p7a3bA6rtJjgOjGlbjdWTdj4+87uBJynsR5CAlUMLeTrjbPe/nWw=="; - Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); - LongSupplier currentTime = () -> (long) 100; - JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - - // encrypt decrypt cluster permissions - assertThat(jwtVendor.encryptString(clusterPermissions), equalTo(encryptedClusterPermissions)); - assertThat(jwtVendor.decryptString(encryptedClusterPermissions), equalTo(clusterPermissions)); - - // encrypt decrypt index permissions - assertThat(jwtVendor.encryptString(indexPermissions), equalTo(encryptedIndexPermissions)); - assertThat(jwtVendor.decryptString(encryptedIndexPermissions), equalTo(indexPermissions)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[audience_0]")); + // 2023 oct 4, 10:00:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("iat")).getTime(), is(1696413600000L)); + // 2023 oct 4, 10:05:00 AM GMT + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime(), is(1696413900000L)); } @Test - public void testKeyTooShortThrowsException() { - String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + public void testKeyTooShortForApiTokenThrowsException() { String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); - Settings settings = Settings.builder().put("signing_key", tooShortKey).put("encryption_key", claimsEncryptionKey).build(); - final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings, Optional.empty()); }); + Settings settings = Settings.builder().put("signing_key", tooShortKey).build(); + final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings); }); assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index 7ecbb6da34..90531dfdb8 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -12,9 +12,11 @@ package org.opensearch.security.identity; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; +import com.google.common.io.BaseEncoding; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -37,16 +39,15 @@ import org.opensearch.security.user.UserService; import org.opensearch.threadpool.ThreadPool; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -76,11 +77,13 @@ public void setup() { @After public void after() { - verifyNoMoreInteractions(cs); - verifyNoMoreInteractions(threadPool); verifyNoMoreInteractions(userService); } + final static String signingKey = + "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; + final static String signingKeyB64Encoded = BaseEncoding.base64().encode(signingKey.getBytes(StandardCharsets.UTF_8)); + @Test public void onConfigModelChanged_oboNotSupported() { final ConfigModel configModel = mock(ConfigModel.class); @@ -94,7 +97,7 @@ public void onConfigModelChanged_oboNotSupported() { @Test public void onDynamicConfigModelChanged_JwtVendorEnabled() { final ConfigModel configModel = mock(ConfigModel.class); - final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(); + final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(true); tokenManager.onConfigModelChanged(configModel); @@ -117,8 +120,12 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { } /** Creates the jwt vendor and returns a mock for validation if needed */ - private DynamicConfigModel createMockJwtVendorInTokenManager() { - final Settings settings = Settings.builder().put("enabled", true).build(); + private DynamicConfigModel createMockJwtVendorInTokenManager(boolean includeEncryptionKey) { + final Settings settings = Settings.builder() + .put("enabled", true) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", (includeEncryptionKey ? "1234567890" : null)) + .build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); @@ -211,11 +218,9 @@ public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenThrow( - new RuntimeException("foobar") - ); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenThrow(new RuntimeException("foobar")); final OpenSearchSecurityException exception = assertThrows( OpenSearchSecurityException.class, () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) @@ -237,10 +242,10 @@ public void issueOnBehalfOfToken_success() throws Exception { tokenManager.onConfigModelChanged(configModel); when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenReturn(authToken); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)); assertThat(returnedToken, equalTo(authToken)); @@ -250,28 +255,94 @@ public void issueOnBehalfOfToken_success() throws Exception { } @Test - public void issueApiToken_success() throws Exception { + public void testCreateJwtWithNegativeExpiry() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", -300L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: The expiration time should be a positive integer")); + } + + @Test + public void testCreateJwtWithExceededExpiry() throws Exception { doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); when(threadPool.getThreadContext()).thenReturn(threadContext); final ConfigModel configModel = mock(ConfigModel.class); tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(true); - final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. + ArgumentCaptor longCaptor = ArgumentCaptor.forClass(Long.class); + verify(jwtVendor).createJwt(any(), any(), any(), longCaptor.capture()); - assertThat(returnedToken, equalTo(authToken)); + assertThat(600L, equalTo(longCaptor.getValue())); + } - verify(cs).getClusterName(); - verify(threadPool).getThreadContext(); + @Test + public void testCreateJwtWithBadEncryptionKey() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(false); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: encryption_key cannot be null")); } @Test - public void encryptCallsJwtEncrypt() throws Exception { + public void testCreateJwtWithBadRoles() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(null); + + createMockJwtVendorInTokenManager(true); + + final Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 90000000L)); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null")); + } + + @Test + public void issueApiToken_success() throws Exception { doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); @@ -279,28 +350,15 @@ public void encryptCallsJwtEncrypt() throws Exception { final ConfigModel configModel = mock(ConfigModel.class); tokenManager.onConfigModelChanged(configModel); - createMockJwtVendorInTokenManager(); + createMockJwtVendorInTokenManager(false); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + when(jwtVendor.createJwt(any(), any(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); assertThat(returnedToken, equalTo(authToken)); verify(cs).getClusterName(); verify(threadPool).getThreadContext(); } - - @Test - public void testEncryptTokenCallsJwtEncrypt() throws Exception { - String tokenToEncrypt = "test-token"; - String encryptedToken = "encrypted-test-token"; - createMockJwtVendorInTokenManager(); - when(jwtVendor.encryptString(tokenToEncrypt)).thenReturn(encryptedToken); - - String result = tokenManager.encryptToken(tokenToEncrypt); - - assertThat(result, equalTo(encryptedToken)); - verify(jwtVendor).encryptString(tokenToEncrypt); - } } diff --git a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java index da35226d62..9fdba2b407 100644 --- a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java @@ -31,6 +31,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.action.apitokens.ApiTokenRepository; import org.opensearch.security.auditlog.NullAuditLog; import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; @@ -160,7 +161,8 @@ PrivilegesEvaluator createPrivilegesEvaluator(SecurityDynamicConfiguration