浏览代码

Add bulk delete roles API (#110383)

* Add bulk delete roles API
Johannes Fredén 1 年之前
父节点
当前提交
89cd966b24
共有 20 个文件被更改,包括 647 次插入72 次删除
  1. 14 0
      docs/build.gradle
  2. 5 0
      docs/changelog/110383.yaml
  3. 2 0
      docs/reference/rest-api/security.asciidoc
  4. 120 0
      docs/reference/rest-api/security/bulk-delete-roles.asciidoc
  5. 43 0
      rest-api-spec/src/main/resources/rest-api-spec/api/security.bulk_delete_role.json
  6. 3 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ActionTypes.java
  7. 59 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkDeleteRolesRequest.java
  8. 1 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkPutRoleRequestBuilder.java
  9. 4 4
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkRolesResponse.java
  10. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  11. 14 0
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java
  12. 112 0
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/BulkDeleteRoleRestIT.java
  13. 0 18
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/BulkPutRoleRestIT.java
  14. 4 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  15. 34 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportBulkDeleteRolesAction.java
  16. 3 4
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportBulkPutRolesAction.java
  17. 118 31
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java
  18. 62 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/role/RestBulkDeleteRolesAction.java
  19. 38 3
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStoreTests.java
  20. 10 9
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/roles/60_bulk_roles.yml

+ 14 - 0
docs/build.gradle

@@ -1815,6 +1815,20 @@ setups['setup-snapshots'] = setups['setup-repository'] + '''
             "run_as": [ "other_user" ],
             "metadata" : {"version": 1}
           }
+'''
+  setups['user_role'] = '''
+  - do:
+      security.put_role:
+        name: "my_user_role"
+        body: >
+          {
+            "description": "Grants user access to some indicies.",
+            "indices": [
+              {"names": ["index1", "index2" ], "privileges": ["all"], "field_security" : {"grant" : [ "title", "body" ]}}
+              ],
+            "metadata" : {"version": 1}
+          }
+
 '''
   setups['jacknich_user'] = '''
   - do:

+ 5 - 0
docs/changelog/110383.yaml

@@ -0,0 +1,5 @@
+pr: 110383
+summary: Add bulk delete roles API
+area: Security
+type: enhancement
+issues: []

+ 2 - 0
docs/reference/rest-api/security.asciidoc

@@ -48,6 +48,7 @@ Use the following APIs to add, remove, update, and retrieve roles in the native
 * <<security-api-bulk-put-role, Bulk create or update roles>>
 * <<security-api-clear-role-cache,Clear roles cache>>
 * <<security-api-delete-role,Delete roles>>
+* <<security-api-bulk-delete-role, Bulk delete roles>>
 * <<security-api-get-role,Get roles>>
 
 [discrete]
@@ -173,6 +174,7 @@ include::security/put-app-privileges.asciidoc[]
 include::security/create-role-mappings.asciidoc[]
 include::security/create-roles.asciidoc[]
 include::security/bulk-create-roles.asciidoc[]
+include::security/bulk-delete-roles.asciidoc[]
 include::security/create-users.asciidoc[]
 include::security/create-service-token.asciidoc[]
 include::security/delegate-pki-authentication.asciidoc[]

+ 120 - 0
docs/reference/rest-api/security/bulk-delete-roles.asciidoc

@@ -0,0 +1,120 @@
+[role="xpack"]
+[[security-api-bulk-delete-role]]
+=== Bulk delete roles API
+preview::[]
+++++
+<titleabbrev>Bulk delete roles API</titleabbrev>
+++++
+
+Bulk deletes roles in the native realm.
+
+[[security-api-bulk-delete-role-request]]
+==== {api-request-title}
+
+`DELETE /_security/role/`
+
+[[security-api-bulk-delete-role-prereqs]]
+==== {api-prereq-title}
+
+* To use this API, you must have at least the `manage_security` cluster
+privilege.
+
+[[security-api-bulk-delete-role-desc]]
+==== {api-description-title}
+
+The role management APIs are generally the preferred way to manage roles, rather than using
+<<roles-management-file,file-based role management>>. The bulk delete roles API cannot delete
+roles that are defined in roles files.
+
+[[security-api-bulk-delete-role-path-params]]
+==== {api-path-parms-title}
+
+`refresh`::
+Optional setting of the {ref}/docs-refresh.html[refresh policy] for the write request. Defaults to Immediate.
+
+[[security-api-bulk-delete-role-request-body]]
+==== {api-request-body-title}
+
+The following parameters can be specified in the body of a DELETE request
+and pertain to deleting a set of roles:
+
+`names`::
+(list) A list of role names to delete.
+
+[[security-bulk-api-delete-role-example]]
+==== {api-examples-title}
+The following example deletes a `my_admin_role` and `my_user_role` roles:
+
+[source,console]
+--------------------------------------------------
+DELETE /_security/role
+{
+    "names": ["my_admin_role", "my_user_role"]
+}
+--------------------------------------------------
+// TEST[setup:admin_role,user_role]
+
+If the roles are successfully deleted, the request returns:
+
+[source,console-result]
+--------------------------------------------------
+{
+    "deleted": [
+        "my_admin_role",
+        "my_user_role"
+    ]
+}
+--------------------------------------------------
+
+If a role cannot be found, the not found roles are grouped under `not_found`:
+
+[source,console]
+--------------------------------------------------
+DELETE /_security/role
+{
+    "names": ["my_admin_role", "not_an_existing_role"]
+}
+--------------------------------------------------
+// TEST[setup:admin_role]
+
+[source,console-result]
+--------------------------------------------------
+{
+    "deleted": [
+        "my_admin_role"
+    ],
+    "not_found": [
+        "not_an_existing_role"
+    ]
+}
+--------------------------------------------------
+
+If a request fails or is invalid, the errors are grouped under `errors`:
+
+[source,console]
+--------------------------------------------------
+DELETE /_security/role
+{
+    "names": ["my_admin_role", "superuser"]
+}
+--------------------------------------------------
+// TEST[setup:admin_role]
+
+
+[source,console-result]
+--------------------------------------------------
+{
+    "deleted": [
+        "my_admin_role"
+    ],
+    "errors": {
+        "count": 1,
+        "details": {
+            "superuser": {
+                "type": "illegal_argument_exception",
+                "reason": "role [superuser] is reserved and cannot be deleted"
+            }
+        }
+    }
+}
+--------------------------------------------------

+ 43 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/security.bulk_delete_role.json

@@ -0,0 +1,43 @@
+{
+  "security.bulk_delete_role": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-bulk-delete-role.html",
+      "description": "Bulk delete roles in the native realm."
+    },
+    "stability": "stable",
+    "visibility": "public",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_security/role",
+          "methods": [
+            "DELETE"
+          ]
+        }
+      ]
+    },
+    "params": {
+      "refresh": {
+        "type": "enum",
+        "options": [
+          "true",
+          "false",
+          "wait_for"
+        ],
+        "description": "If `true` (the default) then refresh the affected shards to make this operation visible to search, if `wait_for` then wait for a refresh to make this operation visible to search, if `false` then do nothing with refreshes."
+      }
+    },
+    "body": {
+      "description": "The roles to delete",
+      "required": true
+    }
+  }
+}

+ 3 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ActionTypes.java

@@ -9,7 +9,7 @@ package org.elasticsearch.xpack.core.security.action;
 
 import org.elasticsearch.action.ActionResponse;
 import org.elasticsearch.action.ActionType;
-import org.elasticsearch.xpack.core.security.action.role.BulkPutRolesResponse;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
 import org.elasticsearch.xpack.core.security.action.role.QueryRoleResponse;
 import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
 
@@ -25,6 +25,7 @@ public final class ActionTypes {
     );
 
     public static final ActionType<QueryUserResponse> QUERY_USER_ACTION = new ActionType<>("cluster:admin/xpack/security/user/query");
+    public static final ActionType<BulkRolesResponse> BULK_PUT_ROLES = new ActionType<>("cluster:admin/xpack/security/role/bulk_put");
     public static final ActionType<QueryRoleResponse> QUERY_ROLE_ACTION = new ActionType<>("cluster:admin/xpack/security/role/query");
-    public static final ActionType<BulkPutRolesResponse> BULK_PUT_ROLES = new ActionType<>("cluster:admin/xpack/security/role/bulk_put");
+    public static final ActionType<BulkRolesResponse> BULK_DELETE_ROLES = new ActionType<>("cluster:admin/xpack/security/role/bulk_delete");
 }

+ 59 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkDeleteRolesRequest.java

@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.role;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.support.WriteRequest;
+
+import java.util.List;
+import java.util.Objects;
+
+public class BulkDeleteRolesRequest extends ActionRequest {
+
+    private List<String> roleNames;
+
+    public BulkDeleteRolesRequest(List<String> roleNames) {
+        this.roleNames = roleNames;
+    }
+
+    private WriteRequest.RefreshPolicy refreshPolicy = WriteRequest.RefreshPolicy.IMMEDIATE;
+
+    @Override
+    public ActionRequestValidationException validate() {
+        // Handle validation where delete role is handled to produce partial success if validation fails
+        return null;
+    }
+
+    public List<String> getRoleNames() {
+        return roleNames;
+    }
+
+    public BulkDeleteRolesRequest setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) {
+        this.refreshPolicy = refreshPolicy;
+        return this;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass() || super.equals(o)) return false;
+
+        BulkDeleteRolesRequest that = (BulkDeleteRolesRequest) o;
+        return Objects.equals(roleNames, that.roleNames);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(roleNames);
+    }
+
+    public WriteRequest.RefreshPolicy getRefreshPolicy() {
+        return refreshPolicy;
+    }
+}

+ 1 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkPutRoleRequestBuilder.java

@@ -27,7 +27,7 @@ import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstr
 /**
  * Builder for requests to bulk add a roles to the security index
  */
-public class BulkPutRoleRequestBuilder extends ActionRequestBuilder<BulkPutRolesRequest, BulkPutRolesResponse> {
+public class BulkPutRoleRequestBuilder extends ActionRequestBuilder<BulkPutRolesRequest, BulkRolesResponse> {
 
     private static final RoleDescriptor.Parser ROLE_DESCRIPTOR_PARSER = RoleDescriptor.parserBuilder().allowDescription(true).build();
     @SuppressWarnings("unchecked")

+ 4 - 4
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkPutRolesResponse.java → x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/BulkRolesResponse.java

@@ -21,7 +21,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
-public class BulkPutRolesResponse extends ActionResponse implements ToXContentObject {
+public class BulkRolesResponse extends ActionResponse implements ToXContentObject {
 
     private final List<Item> items;
 
@@ -34,12 +34,12 @@ public class BulkPutRolesResponse extends ActionResponse implements ToXContentOb
             return this;
         }
 
-        public BulkPutRolesResponse build() {
-            return new BulkPutRolesResponse(items);
+        public BulkRolesResponse build() {
+            return new BulkRolesResponse(items);
         }
     }
 
-    public BulkPutRolesResponse(List<Item> items) {
+    public BulkRolesResponse(List<Item> items) {
         this.items = items;
     }
 

+ 1 - 0
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -281,6 +281,7 @@ public class Constants {
         "cluster:admin/xpack/security/role/query",
         "cluster:admin/xpack/security/role/put",
         "cluster:admin/xpack/security/role/bulk_put",
+        "cluster:admin/xpack/security/role/bulk_delete",
         "cluster:admin/xpack/security/role_mapping/delete",
         "cluster:admin/xpack/security/role_mapping/get",
         "cluster:admin/xpack/security/role_mapping/put",

+ 14 - 0
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java

@@ -187,4 +187,18 @@ public abstract class SecurityOnTrialLicenseRestTestCase extends ESRestTestCase
         );
         assertThat(actual, equalTo(Map.of(expectedRoleDescriptor.getName(), expectedRoleDescriptor)));
     }
+
+    protected Map<String, Object> upsertRoles(String roleDescriptorsByName) throws IOException {
+        Request request = rolesRequest(roleDescriptorsByName);
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+        return responseAsMap(response);
+    }
+
+    protected Request rolesRequest(String roleDescriptorsByName) {
+        Request rolesRequest;
+        rolesRequest = new Request(HttpPost.METHOD_NAME, "/_security/role");
+        rolesRequest.setJsonEntity(org.elasticsearch.core.Strings.format(roleDescriptorsByName));
+        return rolesRequest;
+    }
 }

+ 112 - 0
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/BulkDeleteRoleRestIT.java

@@ -0,0 +1,112 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.role;
+
+import org.apache.http.client.methods.HttpDelete;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.core.Strings;
+import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasKey;
+import static org.hamcrest.Matchers.not;
+
+public class BulkDeleteRoleRestIT extends SecurityOnTrialLicenseRestTestCase {
+    @SuppressWarnings("unchecked")
+    public void testDeleteValidExistingRoles() throws Exception {
+        Map<String, Object> responseMap = upsertRoles("""
+            {"roles": {"test1": {"cluster": ["all"],"indices": [{"names": ["*"],"privileges": ["all"]}]}, "test2":
+            {"cluster": ["all"],"indices": [{"names": ["*"],"privileges": ["read"]}]}, "test3":
+            {"cluster": ["all"],"indices": [{"names": ["*"],"privileges": ["write"]}]}}}""");
+        assertThat(responseMap, not(hasKey("errors")));
+
+        List<String> rolesToDelete = List.of("test1", "test3");
+        Map<String, Object> response = deleteRoles(rolesToDelete);
+        List<String> deleted = (List<String>) response.get("deleted");
+        assertThat(deleted, equalTo(rolesToDelete));
+
+        assertRolesDeleted(rolesToDelete);
+        assertRolesNotDeleted(List.of("test2"));
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testTryDeleteNonExistingRoles() throws Exception {
+        Map<String, Object> responseMap = upsertRoles("""
+            {"roles": {"test1": {"cluster": ["all"],"indices": [{"names": ["*"],"privileges": ["all"]}]}}}""");
+        assertThat(responseMap, not(hasKey("errors")));
+
+        List<String> rolesToDelete = List.of("test1", "test2", "test3");
+
+        Map<String, Object> response = deleteRoles(rolesToDelete);
+        List<String> deleted = (List<String>) response.get("deleted");
+
+        List<String> notFound = (List<String>) response.get("not_found");
+
+        assertThat(deleted, equalTo(List.of("test1")));
+        assertThat(notFound, equalTo(List.of("test2", "test3")));
+
+        assertRolesDeleted(rolesToDelete);
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testTryDeleteReservedRoleName() throws Exception {
+        Map<String, Object> responseMap = upsertRoles("""
+            {"roles": {"test1": {"cluster": ["all"],"indices": [{"names": ["*"],"privileges": ["all"]}]}}}""");
+        assertThat(responseMap, not(hasKey("errors")));
+
+        Map<String, Object> response = deleteRoles(List.of("superuser", "test1"));
+
+        List<String> deleted = (List<String>) response.get("deleted");
+        assertThat(deleted, equalTo(List.of("test1")));
+
+        Map<String, Object> errors = (Map<String, Object>) response.get("errors");
+        assertThat((Integer) errors.get("count"), equalTo(1));
+        Map<String, Object> errorDetails = (Map<String, Object>) ((Map<String, Object>) errors.get("details")).get("superuser");
+
+        assertThat(
+            errorDetails,
+            equalTo(Map.of("type", "illegal_argument_exception", "reason", "role [superuser] is reserved and cannot be deleted"))
+        );
+
+        assertRolesDeleted(List.of("test1"));
+        assertRolesNotDeleted(List.of("superuser"));
+    }
+
+    protected Map<String, Object> deleteRoles(List<String> roles) throws IOException {
+        Request request = new Request(HttpDelete.METHOD_NAME, "/_security/role");
+        request.setJsonEntity(Strings.format("""
+            {"names": [%s]}""", String.join(",", roles.stream().map(role -> "\"" + role + "\"").toList())));
+
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+        return responseAsMap(response);
+    }
+
+    protected void assertRolesDeleted(List<String> roleNames) {
+        for (String roleName : roleNames) {
+            ResponseException exception = assertThrows(
+                ResponseException.class,
+                () -> adminClient().performRequest(new Request("GET", "/_security/role/" + roleName))
+            );
+            assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(404));
+        }
+    }
+
+    protected void assertRolesNotDeleted(List<String> roleNames) throws IOException {
+        for (String roleName : roleNames) {
+            Response response = adminClient().performRequest(new Request("GET", "/_security/role/" + roleName));
+            assertThat(response.getStatusLine().getStatusCode(), equalTo(200));
+        }
+    }
+}

+ 0 - 18
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/BulkPutRoleRestIT.java

@@ -7,14 +7,11 @@
 
 package org.elasticsearch.xpack.security.role;
 
-import org.apache.http.client.methods.HttpPost;
 import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
 import org.elasticsearch.client.ResponseException;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase;
 
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 
@@ -213,19 +210,4 @@ public class BulkPutRoleRestIT extends SecurityOnTrialLicenseRestTestCase {
             assertEquals(3, items.size());
         }
     }
-
-    protected Map<String, Object> upsertRoles(String roleDescriptorsByName) throws IOException {
-        Request request = rolesRequest(roleDescriptorsByName);
-        Response response = adminClient().performRequest(request);
-        assertOK(response);
-        return responseAsMap(response);
-    }
-
-    protected Request rolesRequest(String roleDescriptorsByName) {
-        Request rolesRequest;
-        rolesRequest = new Request(HttpPost.METHOD_NAME, "/_security/role");
-        rolesRequest.setJsonEntity(org.elasticsearch.core.Strings.format(roleDescriptorsByName));
-        return rolesRequest;
-    }
-
 }

+ 4 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -254,6 +254,7 @@ import org.elasticsearch.xpack.security.action.profile.TransportSetProfileEnable
 import org.elasticsearch.xpack.security.action.profile.TransportSuggestProfilesAction;
 import org.elasticsearch.xpack.security.action.profile.TransportUpdateProfileDataAction;
 import org.elasticsearch.xpack.security.action.realm.TransportClearRealmCacheAction;
+import org.elasticsearch.xpack.security.action.role.TransportBulkDeleteRolesAction;
 import org.elasticsearch.xpack.security.action.role.TransportBulkPutRolesAction;
 import org.elasticsearch.xpack.security.action.role.TransportClearRolesCacheAction;
 import org.elasticsearch.xpack.security.action.role.TransportDeleteRoleAction;
@@ -373,6 +374,7 @@ import org.elasticsearch.xpack.security.rest.action.profile.RestGetProfilesActio
 import org.elasticsearch.xpack.security.rest.action.profile.RestSuggestProfilesAction;
 import org.elasticsearch.xpack.security.rest.action.profile.RestUpdateProfileDataAction;
 import org.elasticsearch.xpack.security.rest.action.realm.RestClearRealmCacheAction;
+import org.elasticsearch.xpack.security.rest.action.role.RestBulkDeleteRolesAction;
 import org.elasticsearch.xpack.security.rest.action.role.RestBulkPutRolesAction;
 import org.elasticsearch.xpack.security.rest.action.role.RestClearRolesCacheAction;
 import org.elasticsearch.xpack.security.rest.action.role.RestDeleteRoleAction;
@@ -1540,6 +1542,7 @@ public class Security extends Plugin
             new ActionHandler<>(ActionTypes.QUERY_ROLE_ACTION, TransportQueryRoleAction.class),
             new ActionHandler<>(PutRoleAction.INSTANCE, TransportPutRoleAction.class),
             new ActionHandler<>(ActionTypes.BULK_PUT_ROLES, TransportBulkPutRolesAction.class),
+            new ActionHandler<>(ActionTypes.BULK_DELETE_ROLES, TransportBulkDeleteRolesAction.class),
             new ActionHandler<>(DeleteRoleAction.INSTANCE, TransportDeleteRoleAction.class),
             new ActionHandler<>(TransportChangePasswordAction.TYPE, TransportChangePasswordAction.class),
             new ActionHandler<>(AuthenticateAction.INSTANCE, TransportAuthenticateAction.class),
@@ -1635,6 +1638,7 @@ public class Security extends Plugin
             new RestGetRolesAction(settings, getLicenseState()),
             new RestQueryRoleAction(settings, getLicenseState()),
             new RestBulkPutRolesAction(settings, getLicenseState(), bulkPutRoleRequestBuilderFactory.get()),
+            new RestBulkDeleteRolesAction(settings, getLicenseState()),
             new RestPutRoleAction(settings, getLicenseState(), putRoleRequestBuilderFactory.get()),
             new RestDeleteRoleAction(settings, getLicenseState()),
             new RestChangePasswordAction(settings, securityContext.get(), getLicenseState()),

+ 34 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportBulkDeleteRolesAction.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.security.action.role;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.TransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
+import org.elasticsearch.xpack.core.security.action.role.BulkDeleteRolesRequest;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
+import org.elasticsearch.xpack.security.authz.store.NativeRolesStore;
+
+public class TransportBulkDeleteRolesAction extends TransportAction<BulkDeleteRolesRequest, BulkRolesResponse> {
+
+    private final NativeRolesStore rolesStore;
+
+    @Inject
+    public TransportBulkDeleteRolesAction(ActionFilters actionFilters, NativeRolesStore rolesStore, TransportService transportService) {
+        super(ActionTypes.BULK_DELETE_ROLES.name(), actionFilters, transportService.getTaskManager());
+        this.rolesStore = rolesStore;
+    }
+
+    @Override
+    protected void doExecute(Task task, BulkDeleteRolesRequest request, ActionListener<BulkRolesResponse> listener) {
+        rolesStore.deleteRoles(request.getRoleNames(), request.getRefreshPolicy(), listener);
+    }
+}

+ 3 - 4
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportBulkPutRolesAction.java

@@ -14,11 +14,10 @@ import org.elasticsearch.tasks.Task;
 import org.elasticsearch.transport.TransportService;
 import org.elasticsearch.xpack.core.security.action.ActionTypes;
 import org.elasticsearch.xpack.core.security.action.role.BulkPutRolesRequest;
-import org.elasticsearch.xpack.core.security.action.role.BulkPutRolesResponse;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
 import org.elasticsearch.xpack.security.authz.store.NativeRolesStore;
 
-public class TransportBulkPutRolesAction extends TransportAction<BulkPutRolesRequest, BulkPutRolesResponse> {
-
+public class TransportBulkPutRolesAction extends TransportAction<BulkPutRolesRequest, BulkRolesResponse> {
     private final NativeRolesStore rolesStore;
 
     @Inject
@@ -28,7 +27,7 @@ public class TransportBulkPutRolesAction extends TransportAction<BulkPutRolesReq
     }
 
     @Override
-    protected void doExecute(Task task, final BulkPutRolesRequest request, final ActionListener<BulkPutRolesResponse> listener) {
+    protected void doExecute(Task task, final BulkPutRolesRequest request, final ActionListener<BulkRolesResponse> listener) {
         rolesStore.putRoles(request.getRefreshPolicy(), request.getRoles(), listener);
     }
 }

+ 118 - 31
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java

@@ -49,7 +49,7 @@ import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.security.ScrollHelper;
-import org.elasticsearch.xpack.core.security.action.role.BulkPutRolesResponse;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
 import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction;
 import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheRequest;
 import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheResponse;
@@ -310,7 +310,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
             listener.onFailure(frozenSecurityIndex.getUnavailableReason(PRIMARY_SHARDS));
         } else {
             securityIndex.checkIndexVersionThenExecute(listener::onFailure, () -> {
-                DeleteRequest request = client.prepareDelete(SECURITY_MAIN_ALIAS, getIdForRole(deleteRoleRequest.name())).request();
+                DeleteRequest request = createRoleDeleteRequest(deleteRoleRequest.name());
                 request.setRefreshPolicy(deleteRoleRequest.getRefreshPolicy());
                 executeAsyncWithOrigin(
                     client.threadPool().getThreadContext(),
@@ -338,6 +338,114 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         }
     }
 
+    public void deleteRoles(
+        final List<String> roleNames,
+        WriteRequest.RefreshPolicy refreshPolicy,
+        final ActionListener<BulkRolesResponse> listener
+    ) {
+        if (enabled == false) {
+            listener.onFailure(new IllegalStateException("Native role management is disabled"));
+            return;
+        }
+
+        BulkRequest bulkRequest = new BulkRequest().setRefreshPolicy(refreshPolicy);
+        Map<String, Exception> validationErrorByRoleName = new HashMap<>();
+
+        for (String roleName : roleNames) {
+            if (reservedRoleNameChecker.isReserved(roleName)) {
+                validationErrorByRoleName.put(
+                    roleName,
+                    new IllegalArgumentException("role [" + roleName + "] is reserved and cannot be deleted")
+                );
+            } else {
+                bulkRequest.add(createRoleDeleteRequest(roleName));
+            }
+        }
+
+        if (bulkRequest.numberOfActions() == 0) {
+            bulkResponseWithOnlyValidationErrors(roleNames, validationErrorByRoleName, listener);
+            return;
+        }
+
+        final SecurityIndexManager frozenSecurityIndex = securityIndex.defensiveCopy();
+        if (frozenSecurityIndex.indexExists() == false) {
+            logger.debug("security index does not exist");
+            listener.onResponse(new BulkRolesResponse(List.of()));
+        } else if (frozenSecurityIndex.isAvailable(PRIMARY_SHARDS) == false) {
+            listener.onFailure(frozenSecurityIndex.getUnavailableReason(PRIMARY_SHARDS));
+        } else {
+            securityIndex.checkIndexVersionThenExecute(
+                listener::onFailure,
+                () -> executeAsyncWithOrigin(
+                    client.threadPool().getThreadContext(),
+                    SECURITY_ORIGIN,
+                    bulkRequest,
+                    new ActionListener<BulkResponse>() {
+                        @Override
+                        public void onResponse(BulkResponse bulkResponse) {
+                            bulkResponseAndRefreshRolesCache(roleNames, bulkResponse, validationErrorByRoleName, listener);
+                        }
+
+                        @Override
+                        public void onFailure(Exception e) {
+                            logger.error(() -> "failed to delete roles", e);
+                            listener.onFailure(e);
+                        }
+                    },
+                    client::bulk
+                )
+            );
+        }
+    }
+
+    private void bulkResponseAndRefreshRolesCache(
+        List<String> roleNames,
+        BulkResponse bulkResponse,
+        Map<String, Exception> validationErrorByRoleName,
+        ActionListener<BulkRolesResponse> listener
+    ) {
+        Iterator<BulkItemResponse> bulkItemResponses = bulkResponse.iterator();
+        BulkRolesResponse.Builder bulkPutRolesResponseBuilder = new BulkRolesResponse.Builder();
+        List<String> rolesToRefreshInCache = new ArrayList<>(roleNames.size());
+        roleNames.stream().map(roleName -> {
+            if (validationErrorByRoleName.containsKey(roleName)) {
+                return BulkRolesResponse.Item.failure(roleName, validationErrorByRoleName.get(roleName));
+            }
+            BulkItemResponse resp = bulkItemResponses.next();
+            if (resp.isFailed()) {
+                return BulkRolesResponse.Item.failure(roleName, resp.getFailure().getCause());
+            }
+            if (UPDATE_ROLES_REFRESH_CACHE_RESULTS.contains(resp.getResponse().getResult())) {
+                rolesToRefreshInCache.add(roleName);
+            }
+            return BulkRolesResponse.Item.success(roleName, resp.getResponse().getResult());
+        }).forEach(bulkPutRolesResponseBuilder::addItem);
+
+        clearRoleCache(rolesToRefreshInCache.toArray(String[]::new), ActionListener.wrap(res -> {
+            listener.onResponse(bulkPutRolesResponseBuilder.build());
+        }, listener::onFailure), bulkResponse);
+    }
+
+    private void bulkResponseWithOnlyValidationErrors(
+        List<String> roleNames,
+        Map<String, Exception> validationErrorByRoleName,
+        ActionListener<BulkRolesResponse> listener
+    ) {
+        BulkRolesResponse.Builder bulkRolesResponseBuilder = new BulkRolesResponse.Builder();
+        roleNames.stream()
+            .map(roleName -> BulkRolesResponse.Item.failure(roleName, validationErrorByRoleName.get(roleName)))
+            .forEach(bulkRolesResponseBuilder::addItem);
+
+        listener.onResponse(bulkRolesResponseBuilder.build());
+    }
+
+    private void executeAsyncRolesBulkRequest(BulkRequest bulkRequest, ActionListener<BulkResponse> listener) {
+        securityIndex.checkIndexVersionThenExecute(
+            listener::onFailure,
+            () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, bulkRequest, listener, client::bulk)
+        );
+    }
+
     private Exception validateRoleDescriptor(RoleDescriptor role) {
         ActionRequestValidationException validationException = null;
         validationException = RoleDescriptorRequestValidator.validate(role, validationException);
@@ -423,7 +531,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
     public void putRoles(
         final WriteRequest.RefreshPolicy refreshPolicy,
         final List<RoleDescriptor> roles,
-        final ActionListener<BulkPutRolesResponse> listener
+        final ActionListener<BulkRolesResponse> listener
     ) {
         if (enabled == false) {
             listener.onFailure(new IllegalStateException("Native role management is disabled"));
@@ -454,14 +562,10 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         List<String> roleNames = roles.stream().map(RoleDescriptor::getName).toList();
 
         if (bulkRequest.numberOfActions() == 0) {
-            BulkPutRolesResponse.Builder bulkPutRolesResponseBuilder = new BulkPutRolesResponse.Builder();
-            roleNames.stream()
-                .map(roleName -> BulkPutRolesResponse.Item.failure(roleName, validationErrorByRoleName.get(roleName)))
-                .forEach(bulkPutRolesResponseBuilder::addItem);
-
-            listener.onResponse(bulkPutRolesResponseBuilder.build());
+            bulkResponseWithOnlyValidationErrors(roleNames, validationErrorByRoleName, listener);
             return;
         }
+
         securityIndex.prepareIndexIfNeededThenExecute(
             listener::onFailure,
             () -> executeAsyncWithOrigin(
@@ -471,28 +575,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                 new ActionListener<BulkResponse>() {
                     @Override
                     public void onResponse(BulkResponse bulkResponse) {
-                        List<String> rolesToRefreshInCache = new ArrayList<>(roleNames.size());
-
-                        Iterator<BulkItemResponse> bulkItemResponses = bulkResponse.iterator();
-                        BulkPutRolesResponse.Builder bulkPutRolesResponseBuilder = new BulkPutRolesResponse.Builder();
-
-                        roleNames.stream().map(roleName -> {
-                            if (validationErrorByRoleName.containsKey(roleName)) {
-                                return BulkPutRolesResponse.Item.failure(roleName, validationErrorByRoleName.get(roleName));
-                            }
-                            BulkItemResponse resp = bulkItemResponses.next();
-                            if (resp.isFailed()) {
-                                return BulkPutRolesResponse.Item.failure(roleName, resp.getFailure().getCause());
-                            }
-                            if (UPDATE_ROLES_REFRESH_CACHE_RESULTS.contains(resp.getResponse().getResult())) {
-                                rolesToRefreshInCache.add(roleName);
-                            }
-                            return BulkPutRolesResponse.Item.success(roleName, resp.getResponse().getResult());
-                        }).forEach(bulkPutRolesResponseBuilder::addItem);
-
-                        clearRoleCache(rolesToRefreshInCache.toArray(String[]::new), ActionListener.wrap(res -> {
-                            listener.onResponse(bulkPutRolesResponseBuilder.build());
-                        }, listener::onFailure), bulkResponse);
+                        bulkResponseAndRefreshRolesCache(roleNames, bulkResponse, validationErrorByRoleName, listener);
                     }
 
                     @Override
@@ -520,6 +603,10 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
             .request();
     }
 
+    private DeleteRequest createRoleDeleteRequest(final String roleName) {
+        return client.prepareDelete(SECURITY_MAIN_ALIAS, getIdForRole(roleName)).request();
+    }
+
     private XContentBuilder createRoleXContentBuilder(RoleDescriptor role) throws IOException {
         assert NativeRealmValidationUtil.validateRoleName(role.getName(), false) == null
             : "Role name was invalid or reserved: " + role.getName();

+ 62 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/role/RestBulkDeleteRolesAction.java

@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.security.rest.action.role;
+
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
+import org.elasticsearch.xpack.core.security.action.role.BulkDeleteRolesRequest;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.DELETE;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+
+/**
+ * Rest endpoint to bulk delete roles to the security index
+ */
+public class RestBulkDeleteRolesAction extends NativeRoleBaseRestHandler {
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<BulkDeleteRolesRequest, Void> PARSER = new ConstructingObjectParser<>(
+        "bulk_delete_roles_request",
+        a -> new BulkDeleteRolesRequest((List<String>) a[0])
+    );
+
+    static {
+        PARSER.declareStringArray(constructorArg(), new ParseField("names"));
+    }
+
+    public RestBulkDeleteRolesAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(Route.builder(DELETE, "/_security/role").build());
+    }
+
+    @Override
+    public String getName() {
+        return "security_bulk_delete_roles_action";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
+        BulkDeleteRolesRequest bulkDeleteRolesRequest = PARSER.parse(request.contentParser(), null);
+        if (request.param("refresh") != null) {
+            bulkDeleteRolesRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.parse(request.param("refresh")));
+        }
+        return channel -> client.execute(ActionTypes.BULK_DELETE_ROLES, bulkDeleteRolesRequest, new RestToXContentListener<>(channel));
+    }
+}

+ 38 - 3
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStoreTests.java

@@ -14,6 +14,7 @@ import org.elasticsearch.TransportVersions;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.action.bulk.BulkRequest;
+import org.elasticsearch.action.delete.DeleteRequestBuilder;
 import org.elasticsearch.action.index.IndexRequest;
 import org.elasticsearch.action.index.IndexRequestBuilder;
 import org.elasticsearch.action.support.PlainActionFuture;
@@ -57,7 +58,7 @@ import org.elasticsearch.xcontent.NamedXContentRegistry;
 import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentType;
-import org.elasticsearch.xpack.core.security.action.role.BulkPutRolesResponse;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.IndicesPrivileges;
@@ -123,6 +124,7 @@ public class NativeRolesStoreTests extends ESTestCase {
         when(client.threadPool()).thenReturn(threadPool);
         when(client.prepareIndex(SECURITY_MAIN_ALIAS)).thenReturn(new IndexRequestBuilder(client));
         when(client.prepareUpdate(any(), any())).thenReturn(new UpdateRequestBuilder(client));
+        when(client.prepareDelete(any(), any())).thenReturn(new DeleteRequestBuilder(client, SECURITY_MAIN_ALIAS));
     }
 
     @After
@@ -162,7 +164,7 @@ public class NativeRolesStoreTests extends ESTestCase {
             rolesStore.putRole(WriteRequest.RefreshPolicy.IMMEDIATE, roleDescriptor, actionListener);
         } else {
             rolesStore.putRoles(WriteRequest.RefreshPolicy.IMMEDIATE, List.of(roleDescriptor), ActionListener.wrap(resp -> {
-                BulkPutRolesResponse.Item item = resp.getItems().get(0);
+                BulkRolesResponse.Item item = resp.getItems().get(0);
                 if (item.getResultType().equals("created")) {
                     actionListener.onResponse(true);
                 } else {
@@ -765,13 +767,46 @@ public class NativeRolesStoreTests extends ESTestCase {
             )
             .toList();
 
-        AtomicReference<BulkPutRolesResponse> response = new AtomicReference<>();
+        AtomicReference<BulkRolesResponse> response = new AtomicReference<>();
         AtomicReference<Exception> exception = new AtomicReference<>();
         rolesStore.putRoles(WriteRequest.RefreshPolicy.IMMEDIATE, roleDescriptors, ActionListener.wrap(response::set, exception::set));
         assertNull(exception.get());
         verify(client, times(1)).bulk(any(BulkRequest.class), any());
     }
 
+    public void testBulkDeleteRoles() {
+        final NativeRolesStore rolesStore = createRoleStoreForTest();
+
+        AtomicReference<BulkRolesResponse> response = new AtomicReference<>();
+        AtomicReference<Exception> exception = new AtomicReference<>();
+        rolesStore.deleteRoles(
+            List.of("test-role-1", "test-role-2", "test-role-3"),
+            WriteRequest.RefreshPolicy.IMMEDIATE,
+            ActionListener.wrap(response::set, exception::set)
+        );
+        assertNull(exception.get());
+        verify(client, times(1)).bulk(any(BulkRequest.class), any());
+    }
+
+    public void testBulkDeleteReservedRole() {
+        final NativeRolesStore rolesStore = createRoleStoreForTest();
+
+        AtomicReference<BulkRolesResponse> response = new AtomicReference<>();
+        AtomicReference<Exception> exception = new AtomicReference<>();
+        rolesStore.deleteRoles(
+            List.of("superuser"),
+            WriteRequest.RefreshPolicy.IMMEDIATE,
+            ActionListener.wrap(response::set, exception::set)
+        );
+        assertNull(exception.get());
+        assertThat(response.get().getItems().size(), equalTo(1));
+        BulkRolesResponse.Item item = response.get().getItems().get(0);
+        assertThat(item.getCause().getMessage(), equalTo("role [superuser] is reserved and cannot be deleted"));
+        assertThat(item.getRoleName(), equalTo("superuser"));
+
+        verify(client, times(0)).bulk(any(BulkRequest.class), any());
+    }
+
     private ClusterService mockClusterServiceWithMinNodeVersion(TransportVersion transportVersion) {
         final ClusterService clusterService = mock(ClusterService.class, Mockito.RETURNS_DEEP_STUBS);
         when(clusterService.state().getMinTransportVersion()).thenReturn(transportVersion);

+ 10 - 9
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/roles/60_bulk_roles.yml

@@ -21,16 +21,8 @@ teardown:
       security.delete_user:
         username: "joe"
         ignore: 404
-  - do:
-      security.delete_role:
-        name: "admin_role"
-        ignore: 404
-  - do:
-      security.delete_role:
-        name: "role_with_description"
-        ignore: 404
 ---
-"Test bulk put roles api":
+"Test bulk put and delete roles api":
   - do:
       security.bulk_put_role:
         body:  >
@@ -81,3 +73,12 @@ teardown:
         name: "role_with_description"
   - match: { role_with_description.cluster.0:  "manage_security" }
   - match: { role_with_description.description:  "Allows all security-related operations such as CRUD operations on users and roles and cache clearing." }
+
+  - do:
+      security.bulk_delete_role:
+        body: >
+          {
+             "names": ["admin_role", "role_with_description"]
+          }
+  - match: { deleted.0: "admin_role" }
+  - match: { deleted.1: "role_with_description" }