Browse Source

[Connector Secrets] Add PUT endpoint for connector secrets (#105148)

Navarone Feekery 1 year ago
parent
commit
14169c442e
18 changed files with 684 additions and 3 deletions
  1. 32 0
      rest-api-spec/src/main/resources/rest-api-spec/api/connector_secret.put.json
  2. 5 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java
  3. 71 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/505_connector_secret_put.yml
  4. 11 2
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java
  5. 23 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsIndexService.java
  6. 17 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretAction.java
  7. 120 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretRequest.java
  8. 71 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretResponse.java
  9. 46 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/RestPutConnectorSecretAction.java
  10. 39 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/TransportPutConnectorSecretAction.java
  11. 51 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsIndexServiceTests.java
  12. 12 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsTestUtils.java
  13. 35 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretActionTests.java
  14. 38 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretRequestBWCSerializingTests.java
  15. 39 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretResponseBWCSerializingTests.java
  16. 72 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/TransportPutConnectorSecretActionTests.java
  17. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  18. 1 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java

+ 32 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/connector_secret.put.json

@@ -0,0 +1,32 @@
+{
+  "connector_secret.put": {
+    "documentation": {
+      "url": null,
+      "description": "Creates or updates a secret for a Connector."
+    },
+    "stability": "experimental",
+    "visibility":"private",
+    "headers":{
+      "accept": [ "application/json" ]
+    },
+    "url":{
+      "paths":[
+        {
+          "path":"/_connector/_secret/{id}",
+          "methods":[ "PUT" ],
+          "parts": {
+            "id": {
+              "type": "string",
+              "description": "The unique identifier of the connector secret to be created or updated."
+            }
+          }
+        }
+      ]
+    },
+    "params":{},
+    "body": {
+      "description":"The secret value to store",
+      "required":true
+    }
+  }
+}

+ 5 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java

@@ -335,7 +335,11 @@ public class ClusterPrivilegeResolver {
 
     public static final NamedClusterPrivilege WRITE_CONNECTOR_SECRETS = new ActionClusterPrivilege(
         "write_connector_secrets",
-        Set.of("cluster:admin/xpack/connector/secret/post", "cluster:admin/xpack/connector/secret/delete")
+        Set.of(
+            "cluster:admin/xpack/connector/secret/delete",
+            "cluster:admin/xpack/connector/secret/post",
+            "cluster:admin/xpack/connector/secret/put"
+        )
     );
 
     private static final Map<String, NamedClusterPrivilege> VALUES = sortByAccessLevel(

+ 71 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/505_connector_secret_put.yml

@@ -0,0 +1,71 @@
+setup:
+  - skip:
+      version: " - 8.12.99"
+      reason: Introduced in 8.13.0
+
+---
+'Put connector secret - admin':
+  - do:
+      connector_secret.put:
+        id: test-secret
+        body:
+          value: my-secret
+  - match: { result: 'created' }
+
+  - do:
+      connector_secret.get:
+        id: test-secret
+  - match: { value: my-secret }
+
+  - do:
+      connector_secret.put:
+        id: test-secret
+        body:
+          value: my-secret-2
+  - match: { result: 'updated' }
+
+  - do:
+      connector_secret.get:
+        id: test-secret
+  - match: { value: my-secret-2 }
+
+---
+'Put connector secret - authorized user':
+  - skip:
+      features: headers
+
+  - do:
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      connector_secret.put:
+        id: test-secret
+        body:
+          value: my-secret
+  - match: { result: 'created' }
+
+  - do:
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      connector_secret.get:
+        id: test-secret
+  - match: { value: my-secret }
+
+---
+'Put connector secret - unauthorized user':
+  - skip:
+      features: headers
+
+  - do:
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVucHJpdmlsZWdlZDplbnRzZWFyY2gtdW5wcml2aWxlZ2VkLXVzZXI=" }  # unprivileged
+      connector_secret.put:
+        id: test-secret
+        body:
+          value: my-secret
+      catch: unauthorized
+
+---
+'Put connector secret when id is missing should fail':
+  - do:
+      connector_secret.put:
+        id: test-secret
+        body:
+          value: null
+      catch: bad_request

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

@@ -103,12 +103,15 @@ import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsInd
 import org.elasticsearch.xpack.application.connector.secrets.action.DeleteConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.GetConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretAction;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.RestDeleteConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.RestGetConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.RestPostConnectorSecretAction;
+import org.elasticsearch.xpack.application.connector.secrets.action.RestPutConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.TransportDeleteConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.TransportGetConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.secrets.action.TransportPostConnectorSecretAction;
+import org.elasticsearch.xpack.application.connector.secrets.action.TransportPutConnectorSecretAction;
 import org.elasticsearch.xpack.application.connector.syncjob.action.CancelConnectorSyncJobAction;
 import org.elasticsearch.xpack.application.connector.syncjob.action.CheckInConnectorSyncJobAction;
 import org.elasticsearch.xpack.application.connector.syncjob.action.DeleteConnectorSyncJobAction;
@@ -290,7 +293,8 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
                 List.of(
                     new ActionHandler<>(DeleteConnectorSecretAction.INSTANCE, TransportDeleteConnectorSecretAction.class),
                     new ActionHandler<>(GetConnectorSecretAction.INSTANCE, TransportGetConnectorSecretAction.class),
-                    new ActionHandler<>(PostConnectorSecretAction.INSTANCE, TransportPostConnectorSecretAction.class)
+                    new ActionHandler<>(PostConnectorSecretAction.INSTANCE, TransportPostConnectorSecretAction.class),
+                    new ActionHandler<>(PutConnectorSecretAction.INSTANCE, TransportPutConnectorSecretAction.class)
                 )
             );
         }
@@ -378,7 +382,12 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
 
         if (ConnectorSecretsFeature.isEnabled()) {
             restHandlers.addAll(
-                List.of(new RestGetConnectorSecretAction(), new RestPostConnectorSecretAction(), new RestDeleteConnectorSecretAction())
+                List.of(
+                    new RestDeleteConnectorSecretAction(),
+                    new RestGetConnectorSecretAction(),
+                    new RestPostConnectorSecretAction(),
+                    new RestPutConnectorSecretAction()
+                )
             );
         }
 

+ 23 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsIndexService.java

@@ -12,14 +12,18 @@ import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.DocWriteResponse;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
+import org.elasticsearch.action.support.WriteRequest;
 import org.elasticsearch.client.internal.Client;
 import org.elasticsearch.client.internal.OriginSettingClient;
 import org.elasticsearch.indices.SystemIndexDescriptor;
+import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.application.connector.secrets.action.DeleteConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.GetConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretRequest;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretResponse;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretRequest;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretResponse;
 import org.elasticsearch.xpack.core.template.TemplateUtils;
 
 import java.util.Map;
@@ -96,6 +100,25 @@ public class ConnectorSecretsIndexService {
         }
     }
 
+    public void createSecretWithDocId(PutConnectorSecretRequest request, ActionListener<PutConnectorSecretResponse> listener) {
+
+        String connectorSecretId = request.id();
+
+        try {
+            clientWithOrigin.prepareIndex(CONNECTOR_SECRETS_INDEX_NAME)
+                .setId(connectorSecretId)
+                .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE)
+                .setSource(request.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS))
+                .execute(
+                    listener.delegateFailureAndWrap(
+                        (l, indexResponse) -> l.onResponse(new PutConnectorSecretResponse(indexResponse.getResult()))
+                    )
+                );
+        } catch (Exception e) {
+            listener.onFailure(e);
+        }
+    }
+
     public void deleteSecret(String id, ActionListener<DeleteConnectorSecretResponse> listener) {
         try {
             clientWithOrigin.prepareDelete(CONNECTOR_SECRETS_INDEX_NAME, id)

+ 17 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretAction.java

@@ -0,0 +1,17 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.action.ActionType;
+
+public class PutConnectorSecretAction {
+    public static final String NAME = "cluster:admin/xpack/connector/secret/put";
+    public static final ActionType<PutConnectorSecretResponse> INSTANCE = new ActionType<>(NAME);
+
+    private PutConnectorSecretAction() {/* no instances */}
+}

+ 120 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretRequest.java

@@ -0,0 +1,120 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+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.XContentHelper;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+
+public class PutConnectorSecretRequest extends ActionRequest implements ToXContentObject {
+
+    private final String id;
+    private final String value;
+
+    public PutConnectorSecretRequest(String id, String value) {
+        this.id = id;
+        this.value = value;
+    }
+
+    public PutConnectorSecretRequest(StreamInput in) throws IOException {
+        super(in);
+        this.id = in.readString();
+        this.value = in.readString();
+    }
+
+    public static final ConstructingObjectParser<PutConnectorSecretRequest, String> PARSER = new ConstructingObjectParser<>(
+        "connector_secret_put_request",
+        false,
+        ((args, id) -> new PutConnectorSecretRequest(id, (String) args[0]))
+    );
+
+    static {
+        PARSER.declareString(constructorArg(), new ParseField("value"));
+    }
+
+    public static PutConnectorSecretRequest fromXContentBytes(String id, BytesReference source, XContentType xContentType) {
+        try (XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, source, xContentType)) {
+            return PutConnectorSecretRequest.fromXContent(parser, id);
+        } catch (IOException e) {
+            throw new ElasticsearchParseException("Failed to parse: " + source.utf8ToString(), e);
+        }
+    }
+
+    public static PutConnectorSecretRequest fromXContent(XContentParser parser, String id) throws IOException {
+        return PARSER.parse(parser, id);
+    }
+
+    public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
+        builder.startObject();
+        builder.field("value", value);
+        builder.endObject();
+        return builder;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeString(id);
+        out.writeString(value);
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+
+        ActionRequestValidationException exception = null;
+
+        if (Strings.isNullOrEmpty(id())) {
+            exception = addValidationError("[id] cannot be [null] or [\"\"]", exception);
+        }
+        if (Strings.isNullOrEmpty(value())) {
+            exception = addValidationError("[value] cannot be [null] or [\"\"]", exception);
+        }
+
+        return exception;
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public String value() {
+        return value;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        PutConnectorSecretRequest request = (PutConnectorSecretRequest) o;
+        return Objects.equals(id, request.id) && Objects.equals(value, request.value);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(value);
+    }
+}

+ 71 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretResponse.java

@@ -0,0 +1,71 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.DocWriteResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Objects;
+
+public class PutConnectorSecretResponse extends ActionResponse implements ToXContentObject {
+
+    final DocWriteResponse.Result result;
+
+    public PutConnectorSecretResponse(DocWriteResponse.Result result) {
+        this.result = result;
+    }
+
+    public PutConnectorSecretResponse(StreamInput in) throws IOException {
+        super(in);
+        result = DocWriteResponse.Result.readFrom(in);
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        this.result.writeTo(out);
+    }
+
+    public DocWriteResponse.Result result() {
+        return this.result;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field("result", this.result.getLowercase());
+        builder.endObject();
+        return builder;
+    }
+
+    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;
+        PutConnectorSecretResponse response = (PutConnectorSecretResponse) o;
+        return result == response.result;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(result);
+    }
+}

+ 46 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/RestPutConnectorSecretAction.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.connector.secrets.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.Scope;
+import org.elasticsearch.rest.ServerlessScope;
+import org.elasticsearch.rest.action.RestToXContentListener;
+
+import java.io.IOException;
+import java.util.List;
+
+@ServerlessScope(Scope.INTERNAL)
+public class RestPutConnectorSecretAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "connector_secret_put_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(RestRequest.Method.PUT, "/_connector/_secret/{id}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
+        PutConnectorSecretRequest putSecretRequest = PutConnectorSecretRequest.fromXContentBytes(
+            request.param("id"),
+            request.content(),
+            request.getXContentType()
+        );
+        return restChannel -> client.execute(
+            PutConnectorSecretAction.INSTANCE,
+            putSecretRequest,
+            new RestToXContentListener<>(restChannel, PutConnectorSecretResponse::status, r -> null)
+        );
+    }
+}

+ 39 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/connector/secrets/action/TransportPutConnectorSecretAction.java

@@ -0,0 +1,39 @@
+/*
+ * 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.connector.secrets.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.common.util.concurrent.EsExecutors;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsIndexService;
+
+public class TransportPutConnectorSecretAction extends HandledTransportAction<PutConnectorSecretRequest, PutConnectorSecretResponse> {
+
+    private final ConnectorSecretsIndexService connectorSecretsIndexService;
+
+    @Inject
+    public TransportPutConnectorSecretAction(TransportService transportService, ActionFilters actionFilters, Client client) {
+        super(
+            PutConnectorSecretAction.NAME,
+            transportService,
+            actionFilters,
+            PutConnectorSecretRequest::new,
+            EsExecutors.DIRECT_EXECUTOR_SERVICE
+        );
+        this.connectorSecretsIndexService = new ConnectorSecretsIndexService(client);
+    }
+
+    protected void doExecute(Task task, PutConnectorSecretRequest request, ActionListener<PutConnectorSecretResponse> listener) {
+        connectorSecretsIndexService.createSecretWithDocId(request, listener);
+    }
+}

+ 51 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsIndexServiceTests.java

@@ -9,11 +9,14 @@ package org.elasticsearch.xpack.application.connector.secrets;
 
 import org.elasticsearch.ResourceNotFoundException;
 import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.DocWriteResponse;
 import org.elasticsearch.test.ESSingleNodeTestCase;
 import org.elasticsearch.xpack.application.connector.secrets.action.DeleteConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.GetConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretRequest;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretResponse;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretRequest;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretResponse;
 import org.junit.Before;
 
 import java.util.concurrent.CountDownLatch;
@@ -44,6 +47,21 @@ public class ConnectorSecretsIndexServiceTests extends ESSingleNodeTestCase {
         assertThat(gotSecret.value(), notNullValue());
     }
 
+    public void testUpdateConnectorSecret() throws Exception {
+        String secretId = "secret-id";
+        String value = "my-secret-value";
+
+        PutConnectorSecretRequest updateSecretRequest = new PutConnectorSecretRequest(secretId, value);
+
+        PutConnectorSecretResponse response = awaitPutConnectorSecret(updateSecretRequest);
+        assertThat(response.result(), equalTo(DocWriteResponse.Result.CREATED));
+
+        GetConnectorSecretResponse gotSecret = awaitGetConnectorSecret(secretId);
+
+        assertThat(gotSecret.id(), equalTo(secretId));
+        assertThat(gotSecret.value(), equalTo(value));
+    }
+
     public void testDeleteConnectorSecret() throws Exception {
         PostConnectorSecretRequest createSecretRequest = ConnectorSecretsTestUtils.getRandomPostConnectorSecretRequest();
         PostConnectorSecretResponse createdSecret = awaitPostConnectorSecret(createSecretRequest);
@@ -89,6 +107,39 @@ public class ConnectorSecretsIndexServiceTests extends ESSingleNodeTestCase {
         return response;
     }
 
+    private PutConnectorSecretResponse awaitPutConnectorSecret(PutConnectorSecretRequest secretRequest) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+
+        final AtomicReference<PutConnectorSecretResponse> responseRef = new AtomicReference<>(null);
+        final AtomicReference<Exception> exception = new AtomicReference<>(null);
+
+        connectorSecretsIndexService.createSecretWithDocId(secretRequest, new ActionListener<>() {
+            @Override
+            public void onResponse(PutConnectorSecretResponse putConnectorSecretResponse) {
+                responseRef.set(putConnectorSecretResponse);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exception.set(e);
+                latch.countDown();
+            }
+        });
+
+        if (exception.get() != null) {
+            throw exception.get();
+        }
+
+        boolean requestTimedOut = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        PutConnectorSecretResponse response = responseRef.get();
+
+        assertTrue("Timeout waiting for post request", requestTimedOut);
+        assertNotNull("Received null response from put request", response);
+
+        return response;
+    }
+
     private GetConnectorSecretResponse awaitGetConnectorSecret(String connectorSecretId) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
         final AtomicReference<GetConnectorSecretResponse> resp = new AtomicReference<>(null);

+ 12 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/ConnectorSecretsTestUtils.java

@@ -7,16 +7,20 @@
 
 package org.elasticsearch.xpack.application.connector.secrets;
 
+import org.elasticsearch.action.DocWriteResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.DeleteConnectorSecretRequest;
 import org.elasticsearch.xpack.application.connector.secrets.action.DeleteConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.GetConnectorSecretRequest;
 import org.elasticsearch.xpack.application.connector.secrets.action.GetConnectorSecretResponse;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretRequest;
 import org.elasticsearch.xpack.application.connector.secrets.action.PostConnectorSecretResponse;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretRequest;
+import org.elasticsearch.xpack.application.connector.secrets.action.PutConnectorSecretResponse;
 
 import static org.elasticsearch.test.ESTestCase.randomAlphaOfLength;
 import static org.elasticsearch.test.ESTestCase.randomAlphaOfLengthBetween;
 import static org.elasticsearch.test.ESTestCase.randomBoolean;
+import static org.elasticsearch.test.ESTestCase.randomFrom;
 
 public class ConnectorSecretsTestUtils {
 
@@ -38,6 +42,14 @@ public class ConnectorSecretsTestUtils {
         return new PostConnectorSecretResponse(randomAlphaOfLength(10));
     }
 
+    public static PutConnectorSecretRequest getRandomPutConnectorSecretRequest() {
+        return new PutConnectorSecretRequest(randomAlphaOfLengthBetween(5, 15), randomAlphaOfLengthBetween(1, 20));
+    }
+
+    public static PutConnectorSecretResponse getRandomPutConnectorSecretResponse() {
+        return new PutConnectorSecretResponse(randomFrom(DocWriteResponse.Result.values()));
+    }
+
     public static DeleteConnectorSecretRequest getRandomDeleteConnectorSecretRequest() {
         return new DeleteConnectorSecretRequest(randomAlphaOfLengthBetween(1, 20));
     }

+ 35 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretActionTests.java

@@ -0,0 +1,35 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsTestUtils;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class PutConnectorSecretActionTests extends ESTestCase {
+
+    public void testValidate_WhenConnectorSecretIdIsPresent_ExpectNoValidationError() {
+        PutConnectorSecretRequest request = ConnectorSecretsTestUtils.getRandomPutConnectorSecretRequest();
+        ActionRequestValidationException exception = request.validate();
+
+        assertThat(exception, nullValue());
+    }
+
+    public void testValidate_WhenConnectorSecretIdIsEmpty_ExpectValidationError() {
+        PutConnectorSecretRequest requestWithMissingValue = new PutConnectorSecretRequest("", "");
+        ActionRequestValidationException exception = requestWithMissingValue.validate();
+
+        assertThat(exception, notNullValue());
+        assertThat(exception.getMessage(), containsString("[id] cannot be [null] or [\"\"]"));
+        assertThat(exception.getMessage(), containsString("[value] cannot be [null] or [\"\"]"));
+    }
+}

+ 38 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretRequestBWCSerializingTests.java

@@ -0,0 +1,38 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsTestUtils;
+import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase;
+
+import java.io.IOException;
+
+public class PutConnectorSecretRequestBWCSerializingTests extends AbstractBWCWireSerializationTestCase<PutConnectorSecretRequest> {
+
+    @Override
+    protected Writeable.Reader<PutConnectorSecretRequest> instanceReader() {
+        return PutConnectorSecretRequest::new;
+    }
+
+    @Override
+    protected PutConnectorSecretRequest createTestInstance() {
+        return ConnectorSecretsTestUtils.getRandomPutConnectorSecretRequest();
+    }
+
+    @Override
+    protected PutConnectorSecretRequest mutateInstance(PutConnectorSecretRequest instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected PutConnectorSecretRequest mutateInstanceForVersion(PutConnectorSecretRequest instance, TransportVersion version) {
+        return instance;
+    }
+}

+ 39 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/PutConnectorSecretResponseBWCSerializingTests.java

@@ -0,0 +1,39 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsTestUtils;
+import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase;
+
+import java.io.IOException;
+
+public class PutConnectorSecretResponseBWCSerializingTests extends AbstractBWCWireSerializationTestCase<PutConnectorSecretResponse> {
+
+    @Override
+    protected Writeable.Reader<PutConnectorSecretResponse> instanceReader() {
+        return PutConnectorSecretResponse::new;
+    }
+
+    @Override
+    protected PutConnectorSecretResponse createTestInstance() {
+        return ConnectorSecretsTestUtils.getRandomPutConnectorSecretResponse();
+    }
+
+    @Override
+    protected PutConnectorSecretResponse mutateInstance(PutConnectorSecretResponse instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected PutConnectorSecretResponse mutateInstanceForVersion(PutConnectorSecretResponse instance, TransportVersion version) {
+        return instance;
+    }
+
+}

+ 72 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/connector/secrets/action/TransportPutConnectorSecretActionTests.java

@@ -0,0 +1,72 @@
+/*
+ * 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.connector.secrets.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESSingleNodeTestCase;
+import org.elasticsearch.threadpool.TestThreadPool;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.Transport;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.connector.secrets.ConnectorSecretsTestUtils;
+import org.junit.Before;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.mockito.Mockito.mock;
+
+public class TransportPutConnectorSecretActionTests extends ESSingleNodeTestCase {
+
+    private static final Long TIMEOUT_SECONDS = 10L;
+
+    private final ThreadPool threadPool = new TestThreadPool(getClass().getName());
+    private TransportPutConnectorSecretAction action;
+
+    @Before
+    public void setup() {
+        TransportService transportService = new TransportService(
+            Settings.EMPTY,
+            mock(Transport.class),
+            threadPool,
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet()
+        );
+
+        action = new TransportPutConnectorSecretAction(transportService, mock(ActionFilters.class), client());
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        ThreadPool.terminate(threadPool, TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    public void testPutConnectorSecret_ExpectNoWarnings() throws InterruptedException {
+        PutConnectorSecretRequest request = ConnectorSecretsTestUtils.getRandomPutConnectorSecretRequest();
+
+        executeRequest(request);
+
+        ensureNoWarnings();
+    }
+
+    private void executeRequest(PutConnectorSecretRequest request) throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        action.doExecute(mock(Task.class), request, ActionListener.wrap(response -> latch.countDown(), exception -> latch.countDown()));
+
+        boolean requestTimedOut = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        assertTrue("Timeout waiting for put request", requestTimedOut);
+    }
+}

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

@@ -144,6 +144,7 @@ public class Constants {
         "cluster:admin/xpack/connector/secret/delete",
         "cluster:admin/xpack/connector/secret/get",
         "cluster:admin/xpack/connector/secret/post",
+        "cluster:admin/xpack/connector/secret/put",
         "cluster:admin/xpack/connector/sync_job/cancel",
         "cluster:admin/xpack/connector/sync_job/check_in",
         "cluster:admin/xpack/connector/sync_job/delete",

+ 1 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccountsTests.java

@@ -348,6 +348,7 @@ public class ElasticServiceAccountsTests extends ESTestCase {
         assertThat(role.cluster().check("cluster:admin/xpack/connector/secret/delete", request, authentication), is(true));
         assertThat(role.cluster().check("cluster:admin/xpack/connector/secret/get", request, authentication), is(true));
         assertThat(role.cluster().check("cluster:admin/xpack/connector/secret/post", request, authentication), is(true));
+        assertThat(role.cluster().check("cluster:admin/xpack/connector/secret/put", request, authentication), is(true));
 
         List.of(
             "search-" + randomAlphaOfLengthBetween(1, 20),