Browse Source

Add Put Query Ruleset API call (#96812)

Kathleen DeRusso 2 years ago
parent
commit
008a9fc46d
15 changed files with 574 additions and 36 deletions
  1. 45 0
      rest-api-spec/src/main/resources/rest-api-spec/api/query_ruleset.put.json
  2. 2 1
      test/test-clusters/src/main/java/org/elasticsearch/test/cluster/FeatureFlag.java
  3. 0 7
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java
  4. 1 1
      x-pack/plugin/ent-search/qa/rest/build.gradle
  5. 1 0
      x-pack/plugin/ent-search/qa/rest/roles.yml
  6. 102 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/200_query_ruleset_put.yml
  7. 1 0
      x-pack/plugin/ent-search/src/main/java/module-info.java
  8. 51 27
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java
  9. 157 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/PutQueryRulesetAction.java
  10. 56 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetAction.java
  11. 36 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportPutQueryRulesetAction.java
  12. 30 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/PutQueryRulesetActionRequestSerializingTests.java
  13. 32 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/PutQueryRulesetActionResponseSerializingTests.java
  14. 59 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetActionTests.java
  15. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

+ 45 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/query_ruleset.put.json

@@ -0,0 +1,45 @@
+{
+  "query_ruleset.put": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/put-query-ruleset.html",
+      "description": "Creates or updates a query ruleset."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "es.query_rules_feature_flag_enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_query_rules/{ruleset_id}",
+          "methods": [
+            "PUT"
+          ],
+          "parts": {
+            "ruleset_id": {
+              "type": "string",
+              "description": "The unique identifier of the ruleset to be created or updated."
+            }
+          }
+        }
+      ]
+    },
+    "params": {
+      "create": {
+        "type": "boolean",
+        "description": "If true, requires that a query_ruleset with the specified resource_id does not already exist. (default: false)"
+      }
+    },
+    "body": {
+      "description": "The query ruleset configuration, including `rules`",
+      "required": true
+    }
+  }
+}

+ 2 - 1
test/test-clusters/src/main/java/org/elasticsearch/test/cluster/FeatureFlag.java

@@ -18,7 +18,8 @@ public enum FeatureFlag {
     TIME_SERIES_MODE("es.index_mode_feature_flag_registered=true", Version.fromString("8.0.0"), null),
     NEW_RCS_MODE("es.untrusted_remote_cluster_feature_flag_registered=true", Version.fromString("8.5.0"), null),
     DLM_ENABLED("es.dlm_feature_flag_enabled=true", Version.fromString("8.8.0"), null),
-    SYNONYMS_ENABLED("es.synonyms_feature_flag_enabled=true", Version.fromString("8.9.0"), null);
+    SYNONYMS_ENABLED("es.synonyms_feature_flag_enabled=true", Version.fromString("8.9.0"), null),
+    QUERY_RULES_ENABLED("es.query_rules_feature_flag_enabled=true", Version.fromString("8.9.0"), null);
 
     public final String systemProperty;
     public final Version from;

+ 0 - 7
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java

@@ -107,13 +107,6 @@ public class XPackSettings {
         Setting.Property.NodeScope
     );
 
-    /** Setting for enabling or disabling query rules. Defaults to false. */
-    public static final Setting<Boolean> ENTERPRISE_SEARCH_QUERY_RULES_ENABLED = Setting.boolSetting(
-        "xpack.ent_search.query_rules.enabled",
-        false,
-        Setting.Property.NodeScope
-    );
-
     /** Setting for enabling or disabling auditing. Defaults to false. */
     public static final Setting<Boolean> AUDIT_ENABLED = Setting.boolSetting(
         "xpack.security.audit.enabled",

+ 1 - 1
x-pack/plugin/ent-search/qa/rest/build.gradle

@@ -7,7 +7,7 @@ dependencies {
 
 restResources {
   restApi {
-    include '_common', 'cluster', 'nodes', 'indices', 'index', 'search_application', 'xpack'
+    include '_common', 'cluster', 'nodes', 'indices', 'index', 'query_ruleset', 'search_application', 'xpack'
   }
 }
 

+ 1 - 0
x-pack/plugin/ent-search/qa/rest/roles.yml

@@ -2,6 +2,7 @@ admin:
   cluster:
     - manage_search_application
     - manage_behavioral_analytics
+    - manage
     - monitor
   indices:
     - names: [

+ 102 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/200_query_ruleset_put.yml

@@ -0,0 +1,102 @@
+
+
+---
+'Create Query Ruleset':
+  - do:
+      query_ruleset.put:
+        ruleset_id: test-ruleset
+        body:
+          ruleset_id: test-ruleset
+          rules:
+            - rule_id: query-rule-id1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  value: elastic
+              actions:
+                ids:
+                  - 'id1'
+                  - 'id2'
+            - rule_id: query-rule-id2
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  value: kibana
+              actions:
+                docs:
+                  - '_index': 'test-index1'
+                    '_id': 'id3'
+                  - '_index': 'test-index2'
+                    '_id': 'id4'
+
+  - match: { result: 'created' }
+
+---
+'Create Query Ruleset - Resource already exists':
+  - do:
+      query_ruleset.put:
+        create: true
+        ruleset_id: test-query-ruleset-recreating
+        body:
+          ruleset_id: 'test-query-ruleset-recreating'
+          rules:
+            rule_id: 'test-rule-1'
+            type: 'pinned'
+            criteria:
+              type: 'exact'
+              metadata: 'query_string'
+              value: 'elastic'
+            actions:
+              ids:
+                - 'id1'
+
+  - match: { result: 'created' }
+
+  - do:
+      catch: conflict
+      query_ruleset.put:
+        create: true
+        ruleset_id: test-query-ruleset-recreating
+        body:
+          ruleset_id: 'test-query-ruleset-recreating'
+          rules:
+            rule_id: 'test-rule-1'
+            type: 'pinned'
+            criteria:
+              type: 'exact'
+              metadata: 'query_string'
+              value: 'elastic'
+            actions:
+              ids:
+                - 'id2'
+
+  - match: { error.type: 'version_conflict_engine_exception' }
+
+---
+'Create Query Ruleset - Insufficient privilege':
+  - skip:
+      features: headers
+
+  - do:
+      catch: forbidden
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      query_ruleset.put:
+        ruleset_id: forbidden-query-ruleset
+        create: true
+        body:
+          ruleset_id: 'forbidden-query-ruleset'
+          rules:
+            rule_id: 'test-rule-1'
+            type: 'pinned'
+            criteria:
+              type: 'exact'
+              metadata: 'query_string'
+              value: 'elastic'
+            actions:
+              ids:
+                - 'id1'
+                - 'id2'
+
+  - match: { error.type: 'security_exception' }

+ 1 - 0
x-pack/plugin/ent-search/src/main/java/module-info.java

@@ -30,4 +30,5 @@ module org.elasticsearch.application {
 
     exports org.elasticsearch.xpack.application.search;
     exports org.elasticsearch.xpack.application.search.action;
+    exports org.elasticsearch.xpack.application.rules.action;
 }

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

@@ -20,6 +20,7 @@ import org.elasticsearch.common.settings.IndexScopedSettings;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.settings.SettingsFilter;
+import org.elasticsearch.common.util.FeatureFlag;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.NodeEnvironment;
 import org.elasticsearch.indices.SystemIndexDescriptor;
@@ -52,6 +53,9 @@ import org.elasticsearch.xpack.application.analytics.action.TransportPostAnalyti
 import org.elasticsearch.xpack.application.analytics.action.TransportPutAnalyticsCollectionAction;
 import org.elasticsearch.xpack.application.analytics.ingest.AnalyticsEventIngestConfig;
 import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
+import org.elasticsearch.xpack.application.rules.action.PutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.RestPutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRulesetAction;
 import org.elasticsearch.xpack.application.search.SearchApplicationIndexService;
 import org.elasticsearch.xpack.application.search.action.DeleteSearchApplicationAction;
 import org.elasticsearch.xpack.application.search.action.GetSearchApplicationAction;
@@ -76,6 +80,7 @@ import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.action.XPackInfoFeatureAction;
 import org.elasticsearch.xpack.core.action.XPackUsageFeatureAction;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -90,17 +95,18 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
 
     public static final String BEHAVIORAL_ANALYTICS_API_ENDPOINT = APPLICATION_API_ENDPOINT + "/analytics";
 
+    public static final String QUERY_RULES_API_ENDPOINT = "_query_rules";
+
     private static final Logger logger = LogManager.getLogger(EnterpriseSearch.class);
 
     public static final String FEATURE_NAME = "ent_search";
 
     private final boolean enabled;
 
-    private final boolean queryRulesEnabled;
+    private static final FeatureFlag QUERY_RULES_FEATURE_FLAG = new FeatureFlag("query_rules");
 
     public EnterpriseSearch(Settings settings) {
         this.enabled = XPackSettings.ENTERPRISE_SEARCH_ENABLED.get(settings);
-        this.queryRulesEnabled = XPackSettings.ENTERPRISE_SEARCH_QUERY_RULES_ENABLED.get(settings);
     }
 
     protected XPackLicenseState getLicenseState() {
@@ -114,20 +120,29 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
         if (enabled == false) {
             return List.of(usageAction, infoAction);
         }
-        return List.of(
-            new ActionHandler<>(PutAnalyticsCollectionAction.INSTANCE, TransportPutAnalyticsCollectionAction.class),
-            new ActionHandler<>(GetAnalyticsCollectionAction.INSTANCE, TransportGetAnalyticsCollectionAction.class),
-            new ActionHandler<>(DeleteAnalyticsCollectionAction.INSTANCE, TransportDeleteAnalyticsCollectionAction.class),
-            new ActionHandler<>(PostAnalyticsEventAction.INSTANCE, TransportPostAnalyticsEventAction.class),
-            new ActionHandler<>(DeleteSearchApplicationAction.INSTANCE, TransportDeleteSearchApplicationAction.class),
-            new ActionHandler<>(GetSearchApplicationAction.INSTANCE, TransportGetSearchApplicationAction.class),
-            new ActionHandler<>(ListSearchApplicationAction.INSTANCE, TransportListSearchApplicationAction.class),
-            new ActionHandler<>(PutSearchApplicationAction.INSTANCE, TransportPutSearchApplicationAction.class),
-            new ActionHandler<>(QuerySearchApplicationAction.INSTANCE, TransportQuerySearchApplicationAction.class),
-            new ActionHandler<>(RenderSearchApplicationQueryAction.INSTANCE, TransportRenderSearchApplicationQueryAction.class),
-            usageAction,
-            infoAction
+
+        final List<ActionHandler<? extends ActionRequest, ? extends ActionResponse>> actionHandlers = new ArrayList<>(
+            List.of(
+                new ActionHandler<>(PutAnalyticsCollectionAction.INSTANCE, TransportPutAnalyticsCollectionAction.class),
+                new ActionHandler<>(GetAnalyticsCollectionAction.INSTANCE, TransportGetAnalyticsCollectionAction.class),
+                new ActionHandler<>(DeleteAnalyticsCollectionAction.INSTANCE, TransportDeleteAnalyticsCollectionAction.class),
+                new ActionHandler<>(PostAnalyticsEventAction.INSTANCE, TransportPostAnalyticsEventAction.class),
+                new ActionHandler<>(DeleteSearchApplicationAction.INSTANCE, TransportDeleteSearchApplicationAction.class),
+                new ActionHandler<>(GetSearchApplicationAction.INSTANCE, TransportGetSearchApplicationAction.class),
+                new ActionHandler<>(ListSearchApplicationAction.INSTANCE, TransportListSearchApplicationAction.class),
+                new ActionHandler<>(PutSearchApplicationAction.INSTANCE, TransportPutSearchApplicationAction.class),
+                new ActionHandler<>(QuerySearchApplicationAction.INSTANCE, TransportQuerySearchApplicationAction.class),
+                new ActionHandler<>(RenderSearchApplicationQueryAction.INSTANCE, TransportRenderSearchApplicationQueryAction.class),
+                usageAction,
+                infoAction
+            )
         );
+
+        if (QUERY_RULES_FEATURE_FLAG.isEnabled()) {
+            actionHandlers.add(new ActionHandler<>(PutQueryRulesetAction.INSTANCE, TransportPutQueryRulesetAction.class));
+        }
+
+        return Collections.unmodifiableList(actionHandlers);
     }
 
     @Override
@@ -144,18 +159,27 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
         if (enabled == false) {
             return Collections.emptyList();
         }
-        return List.of(
-            new RestGetSearchApplicationAction(getLicenseState()),
-            new RestListSearchApplicationAction(getLicenseState()),
-            new RestPutSearchApplicationAction(getLicenseState()),
-            new RestDeleteSearchApplicationAction(getLicenseState()),
-            new RestQuerySearchApplicationAction(getLicenseState()),
-            new RestPutAnalyticsCollectionAction(getLicenseState()),
-            new RestGetAnalyticsCollectionAction(getLicenseState()),
-            new RestDeleteAnalyticsCollectionAction(getLicenseState()),
-            new RestPostAnalyticsEventAction(getLicenseState()),
-            new RestRenderSearchApplicationQueryAction(getLicenseState())
+
+        final List<RestHandler> restHandlers = new ArrayList<>(
+            List.of(
+                new RestPutAnalyticsCollectionAction(getLicenseState()),
+                new RestGetAnalyticsCollectionAction(getLicenseState()),
+                new RestDeleteAnalyticsCollectionAction(getLicenseState()),
+                new RestPostAnalyticsEventAction(getLicenseState()),
+                new RestDeleteSearchApplicationAction(getLicenseState()),
+                new RestGetSearchApplicationAction(getLicenseState()),
+                new RestListSearchApplicationAction(getLicenseState()),
+                new RestPutSearchApplicationAction(getLicenseState()),
+                new RestQuerySearchApplicationAction(getLicenseState()),
+                new RestRenderSearchApplicationQueryAction(getLicenseState())
+            )
         );
+
+        if (QUERY_RULES_FEATURE_FLAG.isEnabled()) {
+            restHandlers.add(new RestPutQueryRulesetAction(getLicenseState()));
+        }
+
+        return Collections.unmodifiableList(restHandlers);
     }
 
     @Override
@@ -192,7 +216,7 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
 
     @Override
     public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
-        if (queryRulesEnabled) {
+        if (QUERY_RULES_FEATURE_FLAG.isEnabled()) {
             return Arrays.asList(
                 SearchApplicationIndexService.getSystemIndexDescriptor(),
                 QueryRulesIndexService.getSystemIndexDescriptor()

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

@@ -0,0 +1,157 @@
+/*
+ * 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.action.DocWriteResponse;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.StatusToXContentObject;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.application.rules.QueryRule;
+import org.elasticsearch.xpack.application.rules.QueryRuleset;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class PutQueryRulesetAction extends ActionType<PutQueryRulesetAction.Response> {
+
+    public static final PutQueryRulesetAction INSTANCE = new PutQueryRulesetAction();
+    public static final String NAME = "cluster:admin/xpack/query_rules/put";
+
+    public PutQueryRulesetAction() {
+        super(NAME, PutQueryRulesetAction.Response::new);
+    }
+
+    public static class Request extends ActionRequest {
+
+        private final QueryRuleset queryRuleset;
+        private final boolean create;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.queryRuleset = new QueryRuleset(in);
+            this.create = in.readBoolean();
+        }
+
+        public Request(QueryRuleset queryRuleset, boolean create) {
+            this.queryRuleset = queryRuleset;
+            this.create = create;
+        }
+
+        public Request(String rulesetId, boolean create, BytesReference content, XContentType contentType) {
+            this.queryRuleset = QueryRuleset.fromXContentBytes(rulesetId, content, contentType);
+            this.create = create;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (Strings.isNullOrEmpty(queryRuleset.id())) {
+                validationException = addValidationError("ruleset_id cannot be null or empty", validationException);
+            }
+
+            List<QueryRule> rules = queryRuleset.rules();
+            if (rules == null || rules.isEmpty()) {
+                validationException = addValidationError("rules cannot be null or empty", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            queryRuleset.writeTo(out);
+            out.writeBoolean(create);
+        }
+
+        public QueryRuleset queryRuleset() {
+            return queryRuleset;
+        }
+
+        public boolean create() {
+            return create;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return create == request.create && Objects.equals(queryRuleset, request.queryRuleset);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(queryRuleset, create);
+        }
+    }
+
+    public static class Response extends ActionResponse implements StatusToXContentObject {
+
+        final DocWriteResponse.Result result;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            result = DocWriteResponse.Result.readFrom(in);
+        }
+
+        public Response(DocWriteResponse.Result result) {
+            this.result = result;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            this.result.writeTo(out);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field("result", this.result.getLowercase());
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public RestStatus status() {
+            return switch (result) {
+                case CREATED -> RestStatus.CREATED;
+                case NOT_FOUND -> RestStatus.NOT_FOUND;
+                default -> RestStatus.OK;
+            };
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response that = (Response) o;
+            return Objects.equals(result, that.result);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(result);
+        }
+
+    }
+
+}

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

@@ -0,0 +1,56 @@
+/*
+ * 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.RestStatus;
+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 java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.PUT;
+
+@ServerlessScope(Scope.PUBLIC)
+public class RestPutQueryRulesetAction extends EnterpriseSearchBaseRestHandler {
+    public RestPutQueryRulesetAction(XPackLicenseState licenseState) {
+        super(licenseState);
+    }
+
+    @Override
+    public String getName() {
+        return "query_ruleset_put_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(PUT, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}"));
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) throws IOException {
+        PutQueryRulesetAction.Request request = new PutQueryRulesetAction.Request(
+            restRequest.param("ruleset_id"),
+            restRequest.paramAsBoolean("create", false),
+            restRequest.content(),
+            restRequest.getXContentType()
+        );
+        return channel -> client.execute(PutQueryRulesetAction.INSTANCE, request, new RestToXContentListener<>(channel) {
+            @Override
+            protected RestStatus getStatus(PutQueryRulesetAction.Response response) {
+                return response.status();
+            }
+        });
+    }
+}

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

@@ -0,0 +1,36 @@
+/*
+ * 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.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
+import org.elasticsearch.xpack.application.rules.QueryRuleset;
+
+public class TransportPutQueryRulesetAction extends HandledTransportAction<PutQueryRulesetAction.Request, PutQueryRulesetAction.Response> {
+    protected final QueryRulesIndexService systemIndexService;
+
+    @Inject
+    public TransportPutQueryRulesetAction(TransportService transportService, ActionFilters actionFilters, Client client) {
+        super(PutQueryRulesetAction.NAME, transportService, actionFilters, PutQueryRulesetAction.Request::new);
+        this.systemIndexService = new QueryRulesIndexService(client);
+    }
+
+    @Override
+    protected void doExecute(Task task, PutQueryRulesetAction.Request request, ActionListener<PutQueryRulesetAction.Response> listener) {
+        QueryRuleset queryRuleset = request.queryRuleset();
+        boolean create = request.create();
+        systemIndexService.putQueryRuleset(queryRuleset, create, listener.map(r -> new PutQueryRulesetAction.Response(r.getResult())));
+
+    }
+}

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

@@ -0,0 +1,30 @@
+/*
+ * 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.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.search.SearchApplicationTestUtils;
+
+public class PutQueryRulesetActionRequestSerializingTests extends AbstractWireSerializingTestCase<PutQueryRulesetAction.Request> {
+
+    @Override
+    protected Writeable.Reader<PutQueryRulesetAction.Request> instanceReader() {
+        return PutQueryRulesetAction.Request::new;
+    }
+
+    @Override
+    protected PutQueryRulesetAction.Request createTestInstance() {
+        return new PutQueryRulesetAction.Request(SearchApplicationTestUtils.randomQueryRuleset(), randomBoolean());
+    }
+
+    @Override
+    protected PutQueryRulesetAction.Request mutateInstance(PutQueryRulesetAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

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

@@ -0,0 +1,32 @@
+/*
+ * 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.DocWriteResponse;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class PutQueryRulesetActionResponseSerializingTests extends AbstractWireSerializingTestCase<PutQueryRulesetAction.Response> {
+
+    @Override
+    protected Writeable.Reader<PutQueryRulesetAction.Response> instanceReader() {
+        return PutQueryRulesetAction.Response::new;
+    }
+
+    @Override
+    protected PutQueryRulesetAction.Response createTestInstance() {
+        return new PutQueryRulesetAction.Response(randomFrom(DocWriteResponse.Result.values()));
+    }
+
+    @Override
+    protected PutQueryRulesetAction.Response mutateInstance(PutQueryRulesetAction.Response instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 59 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestPutQueryRulesetActionTests.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.common.bytes.BytesArray;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.application.AbstractRestEnterpriseSearchActionTests;
+import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler;
+
+import java.util.Map;
+
+public class RestPutQueryRulesetActionTests extends AbstractRestEnterpriseSearchActionTests {
+    public void testWithNonCompliantLicense() throws Exception {
+        checkLicenseForRequest(
+            new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withMethod(RestRequest.Method.PUT)
+                .withParams(Map.of("ruleset_id", "ruleset-id"))
+                .withContent(new BytesArray("""
+                    {
+                      "ruleset_id": "ruleset-id",
+                      "rules": [
+                        {
+                          "rule_id": "query-rule-id",
+                          "type": "pinned",
+                          "criteria": [
+                            {
+                              "type": "exact",
+                              "metadata": "query_string",
+                              "value": "elastic"
+                            }
+                          ],
+                          "actions":
+                            {
+                              "ids": [
+                                "id1",
+                                "id2"
+                              ]
+                            }
+                        }
+                      ]
+                    }
+                    """), XContentType.JSON)
+                .build()
+        );
+    }
+
+    @Override
+    protected EnterpriseSearchBaseRestHandler getRestAction(XPackLicenseState licenseState) {
+        return new RestPutQueryRulesetAction(licenseState);
+    }
+}

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

@@ -190,6 +190,7 @@ public class Constants {
         "cluster:admin/xpack/ml/upgrade_mode",
         "cluster:admin/xpack/monitoring/bulk",
         "cluster:admin/xpack/monitoring/migrate/alerts",
+        "cluster:admin/xpack/query_rules/put",
         "cluster:admin/xpack/rollup/delete",
         "cluster:admin/xpack/rollup/put",
         "cluster:admin/xpack/rollup/start",