Forráskód Böngészése

[Query Rules] Add API calls to get or delete individual query rules within a ruleset (#109554)

* Add priority to the query rule index, and merge rule updates into existing rulesets by priority

* Don't require double specification of rule_id

* Initial addition of get and delete API calls

* Add tests

* Update docs/changelog/109554.yaml

* D'oh! Removed commented out code

* Add test

* Update URI for requests and add test

* Ensure URIs are consistent for individual query rule API calls and update constant names to be more explicit that they are rules within a ruleset

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Kathleen DeRusso 1 éve
szülő
commit
32139e45b1
23 módosított fájl, 1166 hozzáadás és 8 törlés
  1. 6 0
      docs/changelog/109554.yaml
  2. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.delete.json
  3. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.get.json
  4. 1 1
      rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.put.json
  5. 1 0
      server/src/main/java/org/elasticsearch/TransportVersions.java
  6. 61 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/50_query_rule_put.yml
  7. 155 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/60_query_rule_delete.yml
  8. 10 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java
  9. 51 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRulesIndexService.java
  10. 136 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/DeleteQueryRuleAction.java
  11. 168 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleAction.java
  12. 1 1
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/PutQueryRuleAction.java
  13. 48 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestDeleteQueryRuleAction.java
  14. 45 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestGetQueryRuleAction.java
  15. 1 1
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java
  16. 48 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportDeleteQueryRuleAction.java
  17. 46 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportGetQueryRuleAction.java
  18. 2 4
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRuleTests.java
  19. 168 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRulesIndexServiceTests.java
  20. 43 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/DeleteQueryRuleActionRequestBWCSerializingTests.java
  21. 43 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleActionRequestBWCSerializingTests.java
  22. 59 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleActionResponseBWCSerializingTests.java
  23. 3 1
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

+ 6 - 0
docs/changelog/109554.yaml

@@ -0,0 +1,6 @@
+pr: 109554
+summary: "[Query Rules] Add API calls to get or delete individual query rules within\
+  \ a ruleset"
+area: Relevance
+type: enhancement
+issues: []

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.delete.json

@@ -0,0 +1,35 @@
+{
+  "query_rule.delete": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/delete-query-rule.html",
+      "description": "Deletes an individual query rule within a ruleset."
+    },
+    "stability": "experimental",
+    "visibility": "public",
+    "headers": {
+      "accept": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_query_rules/{ruleset_id}/_rule/{rule_id}",
+          "methods": [
+            "DELETE"
+          ],
+          "parts": {
+            "ruleset_id": {
+              "type": "string",
+              "description": "The unique identifier of the query ruleset this rule exists in"
+            },
+            "rule_id": {
+              "type": "string",
+              "description": "The unique identifier of the rule to delete."
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.get.json

@@ -0,0 +1,35 @@
+{
+  "query_rule.get": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/get-query-rule.html",
+      "description": "Returns the details about an individual query rule within a ruleset."
+    },
+    "stability": "experimental",
+    "visibility": "public",
+    "headers": {
+      "accept": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_query_rules/{ruleset_id}/_rule/{rule_id}",
+          "methods": [
+            "GET"
+          ],
+          "parts": {
+            "ruleset_id": {
+              "type": "string",
+              "description": "The unique identifier of the query ruleset the rule exists within"
+            },
+            "rule_id": {
+              "type": "string",
+              "description": "The unique identifier of the rule to be retrieved."
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 1 - 1
rest-api-spec/src/main/resources/rest-api-spec/api/query_rule.put.json

@@ -17,7 +17,7 @@
     "url": {
       "paths": [
         {
-          "path": "/_query_rules/{ruleset_id}/{rule_id}",
+          "path": "/_query_rules/{ruleset_id}/_rule/{rule_id}",
           "methods": [
             "PUT"
           ],

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -189,6 +189,7 @@ public class TransportVersions {
     public static final TransportVersion SECURITY_SETTINGS_REQUEST_TIMEOUTS = def(8_680_00_0);
     public static final TransportVersion QUERY_RULE_CRUD_API_PUT = def(8_681_00_0);
     public static final TransportVersion DROP_UNUSED_NODES_REQUESTS = def(8_682_00_0);
+    public static final TransportVersion QUERY_RULE_CRUD_API_GET_DELETE = def(8_683_00_0);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 61 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/50_query_rule_put.yml

@@ -21,6 +21,67 @@ teardown:
         ruleset_id: forbidden-query-ruleset
         ignore: 404
 
+
+---
+'Create query rule with nonexistant ruleset that is also created':
+  - do:
+      query_rule.put:
+        ruleset_id: new-ruleset
+        rule_id: query-rule-id
+        body:
+          type: 'pinned'
+          criteria:
+            type: 'exact'
+            metadata: 'query_string'
+            values: [ 'elastic' ]
+          actions:
+            ids:
+              - 'id1'
+              - 'id2'
+          priority: 5
+
+  - match: { result: 'created' }
+
+  - do:
+      query_rule.get:
+        ruleset_id: new-ruleset
+        rule_id: query-rule-id
+
+  - match: { rule_id: 'query-rule-id' }
+  - match: { type: 'pinned' }
+  - match: { criteria: [ { type: 'exact', metadata: 'query_string', values: [ 'elastic' ] } ] }
+  - match: { actions: { ids: [ 'id1', 'id2' ] } }
+  - match: { priority: 5 }
+
+  # Update the same rule in place
+  - do:
+      query_rule.put:
+        ruleset_id: new-ruleset
+        rule_id: query-rule-id
+        body:
+          type: 'pinned'
+          criteria:
+            type: 'contains'
+            metadata: 'query_string'
+            values: [ 'search' ]
+          actions:
+            ids:
+              - 'id3'
+          priority: 2
+
+  - match: { result: 'updated' }
+
+  - do:
+      query_rule.get:
+        ruleset_id: new-ruleset
+        rule_id: query-rule-id
+
+  - match: { rule_id: 'query-rule-id' }
+  - match: { type: 'pinned' }
+  - match: { criteria: [ { type: 'contains', metadata: 'query_string', values: [ 'search' ] } ] }
+  - match: { actions: { ids: [ 'id3' ] } }
+  - match: { priority: 2 }
+
 ---
 'Create query rule with existing ruleset respecting priority order':
   # Start with 2 rules, one that specifies priority and one that does not (should go at the end)

+ 155 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/60_query_rule_delete.yml

@@ -0,0 +1,155 @@
+setup:
+  - requires:
+      cluster_features: [ "gte_v8.15.0" ]
+      reason: Introduced in 8.15.0
+  - do:
+      query_ruleset.put:
+        ruleset_id: test-query-ruleset
+        body:
+          rules:
+            - rule_id: query-rule-id1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ elastic ]
+              actions:
+                ids:
+                  - 'id1'
+                  - 'id2'
+            - rule_id: query-rule-id2
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'id3'
+                  - 'id4'
+  - do:
+      query_ruleset.put:
+        ruleset_id: test-query-ruleset-to-delete
+        body:
+          rules:
+            - rule_id: query-rule-id1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ elastic ]
+              actions:
+                ids:
+                  - 'id1'
+                  - 'id2'
+            - rule_id: query-rule-id2
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'id3'
+                  - 'id4'
+---
+teardown:
+  - do:
+      query_ruleset.delete:
+        ruleset_id: test-query-ruleset
+        ignore: 404
+
+  - do:
+      query_ruleset.delete:
+        ruleset_id: test-query-ruleset-to-delete
+        ignore: 404
+
+---
+"Delete Query Rule, ruleset still exists":
+  - do:
+      query_rule.delete:
+        ruleset_id: test-query-ruleset
+        rule_id: query-rule-id1
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: "missing"
+      query_rule.get:
+        ruleset_id: test-query-ruleset
+        rule_id: query-rule-id1
+
+  - do:
+      query_ruleset.get:
+        ruleset_id: test-query-ruleset
+
+  - match: { rules.0.rule_id: query-rule-id2 }
+
+---
+"Delete Query Rule, ruleset is also deleted as it is now empty":
+  - do:
+      query_rule.delete:
+        ruleset_id: test-query-ruleset-to-delete
+        rule_id: query-rule-id1
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: "missing"
+      query_rule.get:
+        ruleset_id: test-query-ruleset-to-delete
+        rule_id: query-rule-id1
+
+  - do:
+      query_ruleset.get:
+        ruleset_id: test-query-ruleset-to-delete
+
+  - match: { rules.0.rule_id: query-rule-id2 }
+
+  - do:
+      query_rule.delete:
+        ruleset_id: test-query-ruleset-to-delete
+        rule_id: query-rule-id2
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: "missing"
+      query_rule.get:
+        ruleset_id: test-query-ruleset-to-delete
+        rule_id: query-rule-id2
+
+  - do:
+      catch: "missing"
+      query_ruleset.get:
+        ruleset_id: test-query-ruleset-to-delete
+
+---
+"Delete Query Rule - Rule does not exist":
+  - do:
+      catch: "missing"
+      query_rule.delete:
+        ruleset_id: test-query-ruleset
+        rule_id: nonexistent-rule
+
+---
+"Delete Query Rule - Ruleset does not exist":
+  - do:
+      catch: "missing"
+      query_rule.delete:
+        ruleset_id: nonexistent-query-ruleset
+        rule_id: nonexistent-rule
+
+---
+'Delete Query Ruleset - Insufficient privilege':
+  - skip:
+      features: headers
+
+  - do:
+      catch: forbidden
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      query_rule.delete:
+        ruleset_id: test-query-ruleset
+        rule_id: query-rule-id1
+
+  - match: { error.type: 'security_exception' }

+ 10 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java

@@ -148,17 +148,23 @@ import org.elasticsearch.xpack.application.connector.syncjob.action.UpdateConnec
 import org.elasticsearch.xpack.application.rules.QueryRulesConfig;
 import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
 import org.elasticsearch.xpack.application.rules.RuleQueryBuilder;
+import org.elasticsearch.xpack.application.rules.action.DeleteQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.DeleteQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.GetQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.GetQueryRulesetAction;
 import org.elasticsearch.xpack.application.rules.action.ListQueryRulesetsAction;
 import org.elasticsearch.xpack.application.rules.action.PutQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.PutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.RestDeleteQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.RestDeleteQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.RestGetQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.RestGetQueryRulesetAction;
 import org.elasticsearch.xpack.application.rules.action.RestListQueryRulesetsAction;
 import org.elasticsearch.xpack.application.rules.action.RestPutQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.RestPutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.TransportGetQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.TransportGetQueryRulesetAction;
 import org.elasticsearch.xpack.application.rules.action.TransportListQueryRulesetsAction;
 import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRuleAction;
@@ -254,6 +260,8 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
                 new ActionHandler<>(GetQueryRulesetAction.INSTANCE, TransportGetQueryRulesetAction.class),
                 new ActionHandler<>(ListQueryRulesetsAction.INSTANCE, TransportListQueryRulesetsAction.class),
                 new ActionHandler<>(PutQueryRulesetAction.INSTANCE, TransportPutQueryRulesetAction.class),
+                new ActionHandler<>(DeleteQueryRuleAction.INSTANCE, TransportDeleteQueryRuleAction.class),
+                new ActionHandler<>(GetQueryRuleAction.INSTANCE, TransportGetQueryRuleAction.class),
                 new ActionHandler<>(PutQueryRuleAction.INSTANCE, TransportPutQueryRuleAction.class),
 
                 usageAction,
@@ -359,6 +367,8 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
                 new RestGetQueryRulesetAction(getLicenseState()),
                 new RestListQueryRulesetsAction(getLicenseState()),
                 new RestPutQueryRulesetAction(getLicenseState()),
+                new RestDeleteQueryRuleAction(getLicenseState()),
+                new RestGetQueryRuleAction(getLicenseState()),
                 new RestPutQueryRuleAction(getLicenseState())
             )
         );

+ 51 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRulesIndexService.java

@@ -38,6 +38,7 @@ import org.elasticsearch.search.builder.SearchSourceBuilder;
 import org.elasticsearch.search.sort.SortOrder;
 import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.application.rules.action.DeleteQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.PutQueryRuleAction;
 
 import java.io.IOException;
@@ -51,6 +52,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -226,6 +228,24 @@ public class QueryRulesIndexService {
         });
     }
 
+    /**
+     * Retrieves a {@link QueryRule} from a {@link QueryRuleset}.
+     *
+     * @param rulesetId
+     * @param ruleId
+     * @param listener
+     */
+    public void getQueryRule(String rulesetId, String ruleId, ActionListener<QueryRule> listener) {
+        getQueryRuleset(rulesetId, listener.delegateFailure((delegate, queryRuleset) -> {
+            Optional<QueryRule> maybeQueryRule = queryRuleset.rules().stream().filter(r -> r.id().equals(ruleId)).findFirst();
+            if (maybeQueryRule.isPresent()) {
+                delegate.onResponse(maybeQueryRule.get());
+            } else {
+                delegate.onFailure(new ResourceNotFoundException("rule id " + ruleId + " not found in ruleset " + rulesetId));
+            }
+        }));
+    }
+
     @SuppressWarnings("unchecked")
     private static List<QueryRuleCriteria> parseCriteria(List<Map<String, Object>> rawCriteria) {
         List<QueryRuleCriteria> criteria = new ArrayList<>(rawCriteria.size());
@@ -340,6 +360,37 @@ public class QueryRulesIndexService {
         });
     }
 
+    /**
+     * Deletes a {@link QueryRule} from a {@link QueryRuleset}.
+     *
+     * @param rulesetId
+     * @param ruleId
+     * @param listener
+     */
+    public void deleteQueryRule(String rulesetId, String ruleId, ActionListener<DeleteQueryRuleAction.Response> listener) {
+        getQueryRuleset(rulesetId, listener.delegateFailure((delegate, queryRuleset) -> {
+            Optional<QueryRule> maybeQueryRule = queryRuleset.rules().stream().filter(r -> r.id().equals(ruleId)).findFirst();
+            if (maybeQueryRule.isPresent()) {
+                final List<QueryRule> rules = queryRuleset.rules()
+                    .stream()
+                    .filter(rule -> rule.id().equals(ruleId) == false)
+                    .collect(Collectors.toList());
+                if (rules.isEmpty() == false) {
+                    putQueryRuleset(new QueryRuleset(rulesetId, rules), listener.delegateFailureAndWrap((delegate1, docWriteResponse) -> {
+                        delegate1.onResponse(new DeleteQueryRuleAction.Response(true));
+                    }));
+                } else {
+                    // Delete entire ruleset when there are no more rules left in it
+                    deleteQueryRuleset(rulesetId, listener.delegateFailureAndWrap((delegate1, deleteResponse) -> {
+                        delegate1.onResponse(new DeleteQueryRuleAction.Response(true));
+                    }));
+                }
+            } else {
+                delegate.onFailure(new ResourceNotFoundException("rule id " + ruleId + " not found in ruleset " + rulesetId));
+            }
+        }));
+    }
+
     /**
      * List the {@link QueryRuleset} in ascending order of their ids.
      *

+ 136 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/DeleteQueryRuleAction.java

@@ -0,0 +1,136 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+
+public class DeleteQueryRuleAction {
+
+    public static final String NAME = "cluster:admin/xpack/query_rules/rule/delete";
+    public static final ActionType<AcknowledgedResponse> INSTANCE = new ActionType<>(NAME);
+
+    private DeleteQueryRuleAction() {/* no instances */}
+
+    public static class Request extends ActionRequest implements ToXContentObject {
+        private final String rulesetId;
+        private final String ruleId;
+
+        private static final ParseField RULESET_ID_FIELD = new ParseField("ruleset_id");
+        private static final ParseField RULE_ID_FIELD = new ParseField("rule_id");
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.rulesetId = in.readString();
+            this.ruleId = in.readString();
+        }
+
+        public Request(String rulesetId, String ruleId) {
+            this.rulesetId = rulesetId;
+            this.ruleId = ruleId;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (Strings.isNullOrEmpty(rulesetId)) {
+                validationException = addValidationError("ruleset_id missing", validationException);
+            }
+
+            if (Strings.isNullOrEmpty(ruleId)) {
+                validationException = addValidationError("rule_id missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        public String rulesetId() {
+            return rulesetId;
+        }
+
+        public String ruleId() {
+            return ruleId;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(rulesetId);
+            out.writeString(ruleId);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(rulesetId, that.rulesetId) && Objects.equals(ruleId, that.ruleId);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(rulesetId, ruleId);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field(RULESET_ID_FIELD.getPreferredName(), rulesetId);
+            builder.field(RULE_ID_FIELD.getPreferredName(), ruleId);
+            builder.endObject();
+            return builder;
+        }
+
+        private static final ConstructingObjectParser<Request, String> PARSER = new ConstructingObjectParser<>(
+            "delete_query_rule_request",
+            false,
+            (p) -> new Request((String) p[0], (String) p[1])
+        );
+        static {
+            PARSER.declareString(constructorArg(), RULESET_ID_FIELD);
+            PARSER.declareString(constructorArg(), RULE_ID_FIELD);
+        }
+
+        public static Request parse(XContentParser parser) {
+            return PARSER.apply(parser, null);
+        }
+    }
+
+    public static class Response extends AcknowledgedResponse {
+        public Response(boolean acknowledged) {
+            super(acknowledged);
+        }
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+        }
+    }
+
+}

+ 168 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleAction.java

@@ -0,0 +1,168 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.application.rules.QueryRule;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+
+public class GetQueryRuleAction {
+
+    public static final String NAME = "cluster:admin/xpack/query_rules/rule/get";
+    public static final ActionType<GetQueryRuleAction.Response> INSTANCE = new ActionType<>(NAME);
+
+    private GetQueryRuleAction() {/* no instances */}
+
+    public static class Request extends ActionRequest implements ToXContentObject {
+        private final String rulesetId;
+        private final String ruleId;
+
+        private static final ParseField RULESET_ID_FIELD = new ParseField("ruleset_id");
+        private static final ParseField RULE_ID_FIELD = new ParseField("rule_id");
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.rulesetId = in.readString();
+            this.ruleId = in.readString();
+        }
+
+        public Request(String rulesetId, String ruleId) {
+            this.rulesetId = rulesetId;
+            this.ruleId = ruleId;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (Strings.isNullOrEmpty(rulesetId)) {
+                validationException = addValidationError("ruleset_id missing", validationException);
+            }
+
+            if (Strings.isNullOrEmpty(ruleId)) {
+                validationException = addValidationError("rule_id missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(rulesetId);
+            out.writeString(ruleId);
+        }
+
+        public String rulesetId() {
+            return rulesetId;
+        }
+
+        public String ruleId() {
+            return ruleId;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return Objects.equals(rulesetId, request.rulesetId) && Objects.equals(ruleId, request.ruleId);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(rulesetId, ruleId);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field(RULESET_ID_FIELD.getPreferredName(), rulesetId);
+            builder.field(RULE_ID_FIELD.getPreferredName(), ruleId);
+            builder.endObject();
+            return builder;
+        }
+
+        private static final ConstructingObjectParser<Request, String> PARSER = new ConstructingObjectParser<>(
+            "get_query_rule_request",
+            false,
+            (p) -> new Request((String) p[0], (String) p[1])
+
+        );
+        static {
+            PARSER.declareString(constructorArg(), RULESET_ID_FIELD);
+            PARSER.declareString(constructorArg(), RULE_ID_FIELD);
+        }
+
+        public static Request parse(XContentParser parser, String name) {
+            return PARSER.apply(parser, name);
+        }
+
+    }
+
+    public static class Response extends ActionResponse implements ToXContentObject {
+
+        private final QueryRule queryRule;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.queryRule = new QueryRule(in);
+        }
+
+        public Response(QueryRule queryRule) {
+            this.queryRule = queryRule;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            queryRule.writeTo(out);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            return queryRule.toXContent(builder, params);
+        }
+
+        public QueryRule queryRule() {
+            return queryRule;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+            return Objects.equals(queryRule, response.queryRule);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(queryRule);
+        }
+
+        public static Response fromXContent(XContentParser parser) throws IOException {
+            return new Response(QueryRule.fromXContent(parser));
+        }
+    }
+}

+ 1 - 1
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/PutQueryRuleAction.java

@@ -33,7 +33,7 @@ import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg
 
 public class PutQueryRuleAction {
 
-    public static final String NAME = "cluster:admin/xpack/query_rule/put";
+    public static final String NAME = "cluster:admin/xpack/query_rules/rule/put";
     public static final ActionType<PutQueryRuleAction.Response> INSTANCE = new ActionType<>(NAME);
 
     private PutQueryRuleAction() {/* no instances */}

+ 48 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestDeleteQueryRuleAction.java

@@ -0,0 +1,48 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.Scope;
+import org.elasticsearch.rest.ServerlessScope;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.DELETE;
+
+@ServerlessScope(Scope.PUBLIC)
+public class RestDeleteQueryRuleAction extends EnterpriseSearchBaseRestHandler {
+    public RestDeleteQueryRuleAction(XPackLicenseState licenseState) {
+        super(licenseState, LicenseUtils.Product.QUERY_RULES);
+    }
+
+    @Override
+    public String getName() {
+        return "query_rule_delete_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(DELETE, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}/_rule/{rule_id}"));
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) {
+        DeleteQueryRuleAction.Request request = new DeleteQueryRuleAction.Request(
+            restRequest.param("ruleset_id"),
+            restRequest.param("rule_id")
+        );
+        return channel -> client.execute(DeleteQueryRuleAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 45 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestGetQueryRuleAction.java

@@ -0,0 +1,45 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.Scope;
+import org.elasticsearch.rest.ServerlessScope;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+
+@ServerlessScope(Scope.PUBLIC)
+public class RestGetQueryRuleAction extends EnterpriseSearchBaseRestHandler {
+    public RestGetQueryRuleAction(XPackLicenseState licenseState) {
+        super(licenseState, LicenseUtils.Product.QUERY_RULES);
+    }
+
+    @Override
+    public String getName() {
+        return "query_rule_get_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(GET, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}/_rule/{rule_id}"));
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) {
+        GetQueryRuleAction.Request request = new GetQueryRuleAction.Request(restRequest.param("ruleset_id"), restRequest.param("rule_id"));
+        return channel -> client.execute(GetQueryRuleAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 1 - 1
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRuleAction.java

@@ -35,7 +35,7 @@ public class RestPutQueryRuleAction extends EnterpriseSearchBaseRestHandler {
 
     @Override
     public List<Route> routes() {
-        return List.of(new Route(PUT, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}/{rule_id}"));
+        return List.of(new Route(PUT, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}/_rule/{rule_id}"));
     }
 
     @Override

+ 48 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportDeleteQueryRuleAction.java

@@ -0,0 +1,48 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.util.concurrent.EsExecutors;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
+
+public class TransportDeleteQueryRuleAction extends HandledTransportAction<DeleteQueryRuleAction.Request, AcknowledgedResponse> {
+    protected final QueryRulesIndexService systemIndexService;
+
+    @Inject
+    public TransportDeleteQueryRuleAction(
+        TransportService transportService,
+        ClusterService clusterService,
+        ActionFilters actionFilters,
+        Client client
+    ) {
+        super(
+            DeleteQueryRuleAction.NAME,
+            transportService,
+            actionFilters,
+            DeleteQueryRuleAction.Request::new,
+            EsExecutors.DIRECT_EXECUTOR_SERVICE
+        );
+        this.systemIndexService = new QueryRulesIndexService(client, clusterService.getClusterSettings());
+    }
+
+    @Override
+    protected void doExecute(Task task, DeleteQueryRuleAction.Request request, ActionListener<AcknowledgedResponse> listener) {
+        String rulesetId = request.rulesetId();
+        String ruleId = request.ruleId();
+        systemIndexService.deleteQueryRule(rulesetId, ruleId, listener.map(v -> AcknowledgedResponse.TRUE));
+    }
+}

+ 46 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportGetQueryRuleAction.java

@@ -0,0 +1,46 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.util.concurrent.EsExecutors;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
+
+public class TransportGetQueryRuleAction extends HandledTransportAction<GetQueryRuleAction.Request, GetQueryRuleAction.Response> {
+
+    protected final QueryRulesIndexService systemIndexService;
+
+    @Inject
+    public TransportGetQueryRuleAction(
+        TransportService transportService,
+        ClusterService clusterService,
+        ActionFilters actionFilters,
+        Client client
+    ) {
+        super(
+            GetQueryRuleAction.NAME,
+            transportService,
+            actionFilters,
+            GetQueryRuleAction.Request::new,
+            EsExecutors.DIRECT_EXECUTOR_SERVICE
+        );
+        this.systemIndexService = new QueryRulesIndexService(client, clusterService.getClusterSettings());
+    }
+
+    @Override
+    protected void doExecute(Task task, GetQueryRuleAction.Request request, ActionListener<GetQueryRuleAction.Response> listener) {
+        systemIndexService.getQueryRule(request.rulesetId(), request.ruleId(), listener.map(GetQueryRuleAction.Response::new));
+    }
+}

+ 2 - 4
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRuleTests.java

@@ -28,8 +28,6 @@ import java.util.Map;
 import static java.util.Collections.emptyList;
 import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
 import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
-import static org.elasticsearch.xpack.application.rules.QueryRule.MAX_PRIORITY;
-import static org.elasticsearch.xpack.application.rules.QueryRule.MIN_PRIORITY;
 import static org.elasticsearch.xpack.application.rules.QueryRuleCriteriaType.EXACT;
 import static org.elasticsearch.xpack.application.rules.QueryRuleCriteriaType.PREFIX;
 import static org.elasticsearch.xpack.application.rules.QueryRuleCriteriaType.SUFFIX;
@@ -160,7 +158,7 @@ public class QueryRuleTests extends ESTestCase {
             QueryRule.QueryRuleType.PINNED,
             List.of(new QueryRuleCriteria(EXACT, "query", List.of("elastic"))),
             Map.of("ids", List.of("id1", "id2")),
-            randomBoolean() ? randomIntBetween(MIN_PRIORITY, MAX_PRIORITY) : null
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
@@ -177,7 +175,7 @@ public class QueryRuleTests extends ESTestCase {
             QueryRule.QueryRuleType.PINNED,
             List.of(new QueryRuleCriteria(PREFIX, "query", List.of("elastic")), new QueryRuleCriteria(SUFFIX, "query", List.of("search"))),
             Map.of("ids", List.of("id1", "id2")),
-            randomBoolean() ? randomIntBetween(MIN_PRIORITY, MAX_PRIORITY) : null
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic - you know, for search"));

+ 168 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRulesIndexServiceTests.java

@@ -20,6 +20,8 @@ import org.elasticsearch.plugins.SystemIndexPlugin;
 import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.test.ESSingleNodeTestCase;
 import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils;
+import org.elasticsearch.xpack.application.rules.action.DeleteQueryRuleAction;
+import org.elasticsearch.xpack.application.rules.action.PutQueryRuleAction;
 import org.junit.Before;
 
 import java.util.ArrayList;
@@ -109,6 +111,58 @@ public class QueryRulesIndexServiceTests extends ESSingleNodeTestCase {
         assertThat(getQueryRuleset, equalTo(myQueryRuleset));
     }
 
+    public void testUpdateQueryRule() throws Exception {
+        // Creating a rule in a nonexistent ruleset creates the ruleset
+        final QueryRule myQueryRule1 = new QueryRule(
+            "my_rule1",
+            QueryRuleType.PINNED,
+            List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("foo"))),
+            Map.of("docs", List.of(Map.of("_index", "my_index1", "_id", "id1"), Map.of("_index", "my_index2", "_id", "id2"))),
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
+        );
+        final String rulesetId = "my_ruleset";
+        PutQueryRuleAction.Response newResp = awaitPutQueryRule(rulesetId, myQueryRule1);
+        assertThat(newResp.status(), equalTo(RestStatus.CREATED));
+
+        QueryRuleset getQueryRuleset = awaitGetQueryRuleset(rulesetId);
+        assertThat(getQueryRuleset, equalTo(new QueryRuleset("my_ruleset", List.of(myQueryRule1))));
+        QueryRule getQueryRule = awaitGetQueryRule(rulesetId, "my_rule1");
+        assertThat(getQueryRule, equalTo(myQueryRule1));
+
+        // Updating the same query rule in the ruleset returns OK instead of CREATED
+        final QueryRule updatedQueryRule1 = new QueryRule(
+            "my_rule1",
+            QueryRuleType.PINNED,
+            List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("bar"))),
+            Map.of("docs", List.of(Map.of("_index", "my_index1", "_id", "id2"), Map.of("_index", "my_index2", "_id", "id1"))),
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
+        );
+        PutQueryRuleAction.Response updateResp = awaitPutQueryRule(rulesetId, updatedQueryRule1);
+        assertThat(updateResp.status(), equalTo(RestStatus.OK));
+
+        QueryRuleset getUpdatedQueryRuleset = awaitGetQueryRuleset(rulesetId);
+        assertThat(getUpdatedQueryRuleset, equalTo(new QueryRuleset("my_ruleset", List.of(updatedQueryRule1))));
+        QueryRule getUpdatedQueryRule = awaitGetQueryRule(rulesetId, "my_rule1");
+        assertThat(getUpdatedQueryRule, equalTo(updatedQueryRule1));
+
+        // Creating a new rule in an existing ruleset
+        final QueryRule myQueryRule2 = new QueryRule(
+            "my_rule2",
+            QueryRuleType.PINNED,
+            List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("bar"))),
+            Map.of("docs", List.of(Map.of("_index", "my_index1", "_id", "id3"), Map.of("_index", "my_index2", "_id", "id4"))),
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
+        );
+
+        PutQueryRuleAction.Response addResp = awaitPutQueryRule(rulesetId, myQueryRule2);
+        assertThat(addResp.status(), equalTo(RestStatus.CREATED));
+
+        QueryRuleset getQueryRuleset2 = awaitGetQueryRuleset(rulesetId);
+        assertThat(getQueryRuleset2, equalTo(new QueryRuleset("my_ruleset", List.of(updatedQueryRule1, myQueryRule2))));
+        QueryRule getQueryRule2 = awaitGetQueryRule(rulesetId, "my_rule2");
+        assertThat(getQueryRule2, equalTo(myQueryRule2));
+    }
+
     public void testListQueryRulesets() throws Exception {
         int numRulesets = 10;
         for (int i = 0; i < numRulesets; i++) {
@@ -205,6 +259,45 @@ public class QueryRulesIndexServiceTests extends ESSingleNodeTestCase {
         expectThrows(ResourceNotFoundException.class, () -> awaitGetQueryRuleset("my_ruleset"));
     }
 
+    public void testDeleteQueryRule() throws Exception {
+        for (int i = 0; i < 5; i++) {
+            final QueryRule myQueryRule1 = new QueryRule(
+                "my_rule1",
+                QueryRuleType.PINNED,
+                List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("foo"))),
+                Map.of("ids", List.of("id1", "id2")),
+                randomBoolean() ? randomIntBetween(0, 100) : null
+            );
+            final QueryRule myQueryRule2 = new QueryRule(
+                "my_rule2",
+                QueryRuleType.PINNED,
+                List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("bar"))),
+                Map.of("ids", List.of("id3", "id4")),
+                randomBoolean() ? randomIntBetween(0, 100) : null
+            );
+            final QueryRuleset myQueryRuleset = new QueryRuleset("my_ruleset", List.of(myQueryRule1, myQueryRule2));
+            DocWriteResponse resp = awaitPutQueryRuleset(myQueryRuleset);
+            assertThat(resp.status(), anyOf(equalTo(RestStatus.CREATED), equalTo(RestStatus.OK)));
+            assertThat(resp.getIndex(), equalTo(QUERY_RULES_CONCRETE_INDEX_NAME));
+
+            QueryRule getQueryRule = awaitGetQueryRule("my_ruleset", "my_rule1");
+            assertThat(getQueryRule, equalTo(myQueryRule1));
+
+            DeleteQueryRuleAction.Response deleteResp = awaitDeleteQueryRule("my_ruleset", "my_rule1");
+            assertThat(deleteResp.isAcknowledged(), equalTo(true));
+            expectThrows(ResourceNotFoundException.class, () -> awaitGetQueryRule("my_ruleset", "my_rule1"));
+
+            QueryRule getQueryRule2 = awaitGetQueryRule("my_ruleset", "my_rule2");
+            assertThat(getQueryRule2, equalTo(myQueryRule2));
+        }
+
+        // Deleting the last rule in the ruleset should delete the ruleset
+        DeleteQueryRuleAction.Response deleteResp = awaitDeleteQueryRule("my_ruleset", "my_rule2");
+        assertThat(deleteResp.isAcknowledged(), equalTo(true));
+        expectThrows(ResourceNotFoundException.class, () -> awaitGetQueryRule("my_ruleset", "my_rule2"));
+        expectThrows(ResourceNotFoundException.class, () -> awaitGetQueryRuleset("my_ruleset"));
+    }
+
     private DocWriteResponse awaitPutQueryRuleset(QueryRuleset queryRuleset) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
         final AtomicReference<DocWriteResponse> resp = new AtomicReference<>(null);
@@ -230,6 +323,31 @@ public class QueryRulesIndexServiceTests extends ESSingleNodeTestCase {
         return resp.get();
     }
 
+    private PutQueryRuleAction.Response awaitPutQueryRule(String queryRulesetId, QueryRule queryRule) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<PutQueryRuleAction.Response> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        queryRulesIndexService.putQueryRule(queryRulesetId, queryRule, new ActionListener<>() {
+            @Override
+            public void onResponse(PutQueryRuleAction.Response indexResponse) {
+                resp.set(indexResponse);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue("Timeout waiting for put request", latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull("Received null response from put request", resp.get());
+        return resp.get();
+    }
+
     private QueryRuleset awaitGetQueryRuleset(String name) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
         final AtomicReference<QueryRuleset> resp = new AtomicReference<>(null);
@@ -255,6 +373,31 @@ public class QueryRulesIndexServiceTests extends ESSingleNodeTestCase {
         return resp.get();
     }
 
+    private QueryRule awaitGetQueryRule(String rulesetId, String ruleId) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<QueryRule> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        queryRulesIndexService.getQueryRule(rulesetId, ruleId, new ActionListener<>() {
+            @Override
+            public void onResponse(QueryRule rule) {
+                resp.set(rule);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue("Timeout waiting for get request", latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull("Received null response from get request", resp.get());
+        return resp.get();
+    }
+
     private DeleteResponse awaitDeleteQueryRuleset(String name) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
         final AtomicReference<DeleteResponse> resp = new AtomicReference<>(null);
@@ -280,6 +423,31 @@ public class QueryRulesIndexServiceTests extends ESSingleNodeTestCase {
         return resp.get();
     }
 
+    private DeleteQueryRuleAction.Response awaitDeleteQueryRule(String rulesetId, String ruleId) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<DeleteQueryRuleAction.Response> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        queryRulesIndexService.deleteQueryRule(rulesetId, ruleId, new ActionListener<>() {
+            @Override
+            public void onResponse(DeleteQueryRuleAction.Response deleteResponse) {
+                resp.set(deleteResponse);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue("Timeout waiting for delete request", latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull("Received null response from delete request", resp.get());
+        return resp.get();
+    }
+
     private QueryRulesIndexService.QueryRulesetResult awaitListQueryRulesets(int from, int size) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
         final AtomicReference<QueryRulesIndexService.QueryRulesetResult> resp = new AtomicReference<>(null);

+ 43 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/DeleteQueryRuleActionRequestBWCSerializingTests.java

@@ -0,0 +1,43 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.core.ml.AbstractBWCSerializationTestCase;
+
+import java.io.IOException;
+
+public class DeleteQueryRuleActionRequestBWCSerializingTests extends AbstractBWCSerializationTestCase<DeleteQueryRuleAction.Request> {
+
+    @Override
+    protected Writeable.Reader<DeleteQueryRuleAction.Request> instanceReader() {
+        return DeleteQueryRuleAction.Request::new;
+    }
+
+    @Override
+    protected DeleteQueryRuleAction.Request createTestInstance() {
+        return new DeleteQueryRuleAction.Request(randomAlphaOfLengthBetween(1, 10), randomAlphaOfLengthBetween(1, 10));
+    }
+
+    @Override
+    protected DeleteQueryRuleAction.Request mutateInstance(DeleteQueryRuleAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected DeleteQueryRuleAction.Request doParseInstance(XContentParser parser) throws IOException {
+        return DeleteQueryRuleAction.Request.parse(parser);
+    }
+
+    @Override
+    protected DeleteQueryRuleAction.Request mutateInstanceForVersion(DeleteQueryRuleAction.Request instance, TransportVersion version) {
+        return instance;
+    }
+}

+ 43 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleActionRequestBWCSerializingTests.java

@@ -0,0 +1,43 @@
+/*
+ * 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.application.rules.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.core.ml.AbstractBWCSerializationTestCase;
+
+import java.io.IOException;
+
+public class GetQueryRuleActionRequestBWCSerializingTests extends AbstractBWCSerializationTestCase<GetQueryRuleAction.Request> {
+
+    @Override
+    protected Writeable.Reader<GetQueryRuleAction.Request> instanceReader() {
+        return GetQueryRuleAction.Request::new;
+    }
+
+    @Override
+    protected GetQueryRuleAction.Request createTestInstance() {
+        return new GetQueryRuleAction.Request(randomAlphaOfLengthBetween(1, 10), randomAlphaOfLengthBetween(1, 10));
+    }
+
+    @Override
+    protected GetQueryRuleAction.Request mutateInstance(GetQueryRuleAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected GetQueryRuleAction.Request doParseInstance(XContentParser parser) throws IOException {
+        return GetQueryRuleAction.Request.parse(parser, null);
+    }
+
+    @Override
+    protected GetQueryRuleAction.Request mutateInstanceForVersion(GetQueryRuleAction.Request instance, TransportVersion version) {
+        return instance;
+    }
+}

+ 59 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/GetQueryRuleActionResponseBWCSerializingTests.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.application.rules.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.TransportVersions;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.application.rules.QueryRule;
+import org.elasticsearch.xpack.core.ml.AbstractBWCSerializationTestCase;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils.randomQueryRule;
+import static org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase.getAllBWCVersions;
+
+public class GetQueryRuleActionResponseBWCSerializingTests extends AbstractBWCSerializationTestCase<GetQueryRuleAction.Response> {
+    public QueryRule queryRule;
+
+    @Override
+    protected Writeable.Reader<GetQueryRuleAction.Response> instanceReader() {
+        return GetQueryRuleAction.Response::new;
+    }
+
+    @Override
+    protected GetQueryRuleAction.Response createTestInstance() {
+        this.queryRule = randomQueryRule();
+        return new GetQueryRuleAction.Response(this.queryRule);
+    }
+
+    @Override
+    protected GetQueryRuleAction.Response mutateInstance(GetQueryRuleAction.Response instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected GetQueryRuleAction.Response doParseInstance(XContentParser parser) throws IOException {
+        return GetQueryRuleAction.Response.fromXContent(parser);
+    }
+
+    @Override
+    protected GetQueryRuleAction.Response mutateInstanceForVersion(GetQueryRuleAction.Response instance, TransportVersion version) {
+        return instance;
+    }
+
+    @Override
+    protected List<TransportVersion> bwcVersions() {
+        return getAllBWCVersions().stream()
+            .filter(v -> v.onOrAfter(TransportVersions.QUERY_RULE_CRUD_API_GET_DELETE))
+            .collect(Collectors.toList());
+    }
+}

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

@@ -232,7 +232,9 @@ public class Constants {
         "cluster:admin/xpack/ml/upgrade_mode",
         "cluster:admin/xpack/monitoring/bulk",
         "cluster:admin/xpack/monitoring/migrate/alerts",
-        "cluster:admin/xpack/query_rule/put",
+        "cluster:admin/xpack/query_rules/rule/delete",
+        "cluster:admin/xpack/query_rules/rule/get",
+        "cluster:admin/xpack/query_rules/rule/put",
         "cluster:admin/xpack/query_rules/delete",
         "cluster:admin/xpack/query_rules/get",
         "cluster:admin/xpack/query_rules/list",