Repository: opensearch-project/opensearch-go
Pull request #81 (2022-01-05 04:41:41)

Support Signing Request feature
### Description
* Add support to sign request
* Add support to sign using AWS Sigv4
 
### Issues Resolved
#75 
 
### Check List
- [x] Commits are signed per the DCO using --signoff 

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check [here](https://github.com/opensearch-project/OpenSearch/blob/main/CONTRIBUTING.md#developer-certificate-of-origin).



Patch:
diff --git a/go.mod b/go.mod
index f4a3b82..5e672da 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module github.com/opensearch-project/opensearch-go
 
 go 1.11
 
-require github.com/stretchr/testify v1.7.0 // indirect
+require (
+	github.com/aws/aws-sdk-go v1.42.27
+	github.com/stretchr/testify v1.7.0
+)
diff --git a/go.sum b/go.sum
index b380ae4..076f498 100644
--- a/go.sum
+++ b/go.sum
@@ -1,10 +1,28 @@
+github.com/aws/aws-sdk-go v1.42.27 h1:kxsBXQg3ee6LLbqjp5/oUeDgG7TENFrWYDmEVnd7spU=
+github.com/aws/aws-sdk-go v1.42.27/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/opensearch.go b/opensearch.go
index 28d8ebe..cb6e733 100644
--- a/opensearch.go
+++ b/opensearch.go
@@ -42,6 +42,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/opensearch-project/opensearch-go/signer"
+
 	"github.com/opensearch-project/opensearch-go/internal/version"
 	"github.com/opensearch-project/opensearch-go/opensearchapi"
 	"github.com/opensearch-project/opensearch-go/opensearchtransport"
@@ -77,6 +79,8 @@ type Config struct {
 
 	Header http.Header // Global HTTP request header.
 
+	Signer signer.Signer
+
 	// PEM-encoded certificate authorities.
 	// When set, an empty certificate pool will be created, and the certificates will be appended to it.
 	// The option is only valid when the transport is not specified, or when it's http.Transport.
@@ -189,6 +193,8 @@ func NewClient(cfg Config) (*Client, error) {
 		Header: cfg.Header,
 		CACert: cfg.CACert,
 
+		Signer: cfg.Signer,
+
 		RetryOnStatus:        cfg.RetryOnStatus,
 		DisableRetry:         cfg.DisableRetry,
 		EnableRetryOnTimeout: cfg.EnableRetryOnTimeout,
diff --git a/opensearchtransport/opensearchtransport.go b/opensearchtransport/opensearchtransport.go
index e8a025a..725fddc 100644
--- a/opensearchtransport/opensearchtransport.go
+++ b/opensearchtransport/opensearchtransport.go
@@ -45,6 +45,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/opensearch-project/opensearch-go/signer"
+
 	"github.com/opensearch-project/opensearch-go/internal/version"
 )
 
@@ -88,6 +90,8 @@ type Config struct {
 	Header http.Header
 	CACert []byte
 
+	Signer signer.Signer
+
 	RetryOnStatus        []int
 	DisableRetry         bool
 	EnableRetryOnTimeout bool
@@ -118,6 +122,8 @@ type Client struct {
 	password string
 	header   http.Header
 
+	signer signer.Signer
+
 	retryOnStatus         []int
 	disableRetry          bool
 	enableRetryOnTimeout  bool
@@ -181,6 +187,8 @@ func New(cfg Config) (*Client, error) {
 		password: cfg.Password,
 		header:   cfg.Header,
 
+		signer: cfg.Signer,
+
 		retryOnStatus:         cfg.RetryOnStatus,
 		disableRetry:          cfg.DisableRetry,
 		enableRetryOnTimeout:  cfg.EnableRetryOnTimeout,
@@ -309,6 +317,10 @@ func (c *Client) Perform(req *http.Request) (*http.Response, error) {
 		c.setReqURL(conn.URL, req)
 		c.setReqAuth(conn.URL, req)
 
+		if err = c.signRequest(req); err != nil {
+			return nil, fmt.Errorf("failed to sign request: %s", err)
+		}
+
 		if !c.disableRetry && i > 0 && req.Body != nil && req.Body != http.NoBody {
 			body, err := req.GetBody()
 			if err != nil {
@@ -439,6 +451,13 @@ func (c *Client) setReqAuth(u *url.URL, req *http.Request) *http.Request {
 	return req
 }
 
+func (c *Client) signRequest(req *http.Request) error {
+	if c.signer != nil {
+		return c.signer.SignRequest(req)
+	}
+	return nil
+}
+
 func (c *Client) setReqUserAgent(req *http.Request) *http.Request {
 	req.Header.Set("User-Agent", userAgent)
 	return req
diff --git a/signer/aws/aws.go b/signer/aws/aws.go
new file mode 100644
index 0000000..31999f8
--- /dev/null
+++ b/signer/aws/aws.go
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// The OpenSearch Contributors require contributions made to
+// this file be licensed under the Apache-2.0 license or a
+// compatible open source license.
+//
+// Modifications Copyright OpenSearch Contributors. See
+// GitHub history for details.
+
+package aws
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws/session"
+	v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
+)
+
+// OpenSearchService AWS OpenSearchService Name
+const OpenSearchService = "es"
+
+// Signer is a interface that will implement opensearchtransport.Signer
+type Signer struct {
+	session session.Session
+	service string
+}
+
+// NewSigner returns an instance of Signer
+func NewSigner(opts session.Options) (*Signer, error) {
+	awsSession, err := session.NewSessionWithOptions(opts)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session from given option %v due to %s", opts, err)
+	}
+	return &Signer{
+		session: *awsSession,
+		service: OpenSearchService,
+	}, nil
+}
+
+// SignRequest signs the request using SigV4
+func (s Signer) SignRequest(req *http.Request) error {
+	signer := v4.NewSigner(s.session.Config.Credentials)
+	return sign(req, s.session.Config.Region, s.service, signer)
+
+}
+
+func sign(req *http.Request, region *string, serviceName string, signer *v4.Signer) (err error) {
+	if region == nil || len(*region) == 0 {
+		return fmt.Errorf("aws region cannot be empty")
+	}
+	if req.Body == nil {
+		_, err = signer.Sign(req, nil, serviceName, *region, time.Now().UTC())
+		return
+	}
+	buf, err := io.ReadAll(req.Body)
+	if err != nil {
+		return err
+	}
+	_, err = signer.Sign(req, bytes.NewReader(buf), serviceName, *region, time.Now().UTC())
+	return
+}
diff --git a/signer/signer.go b/signer/signer.go
new file mode 100644
index 0000000..f0a88e7
--- /dev/null
+++ b/signer/signer.go
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// The OpenSearch Contributors require contributions made to
+// this file be licensed under the Apache-2.0 license or a
+// compatible open source license.
+//
+// Modifications Copyright OpenSearch Contributors. See
+// GitHub history for details.
+
+package signer
+
+import "net/http"
+
+//Signer an interface that will sign http.Request
+type Signer interface {
+	SignRequest(request *http.Request) error
+}


Test patch:
diff --git a/opensearchtransport/opensearchtransport_internal_test.go b/opensearchtransport/opensearchtransport_internal_test.go
index 1bc9946..8e3a327 100644
--- a/opensearchtransport/opensearchtransport_internal_test.go
+++ b/opensearchtransport/opensearchtransport_internal_test.go
@@ -61,6 +61,20 @@ func (t *mockTransp) RoundTrip(req *http.Request) (*http.Response, error) {
 
 type mockNetError struct{ error }
 
+type mockSigner struct {
+	SampleKey   string
+	SampleValue string
+	ReturnError bool
+}
+
+func (m *mockSigner) SignRequest(req *http.Request) error {
+	if m.ReturnError {
+		return fmt.Errorf("invalid data")
+	}
+	req.Header.Add(m.SampleKey, m.SampleValue)
+	return nil
+}
+
 func (e *mockNetError) Timeout() bool   { return false }
 func (e *mockNetError) Temporary() bool { return false }
 
@@ -383,6 +397,26 @@ func TestTransportPerform(t *testing.T) {
 		}
 	})
 
+	t.Run("Sign request", func(t *testing.T) {
+		u, _ := url.Parse("https://foo:bar@example.com")
+		tp, _ := New(
+			Config{
+				URLs: []*url.URL{u},
+				Signer: &mockSigner{
+					SampleKey:   "sign-status",
+					SampleValue: "success",
+				},
+			},
+		)
+
+		req, _ := http.NewRequest("GET", "/", nil)
+		tp.signRequest(req)
+
+		if _, ok := req.Header["Sign-Status"]; !ok {
+			t.Error("Signature is not added")
+		}
+	})
+
 	t.Run("Error No URL", func(t *testing.T) {
 		tp, _ := New(Config{
 			URLs: []*url.URL{},
@@ -972,3 +1006,31 @@ func TestRequestCompression(t *testing.T) {
 		})
 	}
 }
+
+func TestRequestSigning(t *testing.T) {
+
+	t.Run("Sign request fails", func(t *testing.T) {
+		u, _ := url.Parse("https://foo:bar@example.com")
+		tp, _ := New(
+			Config{
+				URLs: []*url.URL{u},
+				Signer: &mockSigner{
+					ReturnError: true,
+				},
+				Transport: &mockTransp{
+					RoundTripFunc: func(req *http.Request) (*http.Response, error) {
+						return &http.Response{Status: "MOCK"}, nil
+					},
+				},
+			},
+		)
+		req, _ := http.NewRequest("GET", "/", nil)
+		_, err := tp.Perform(req)
+		if err == nil {
+			t.Fatal("Expected error, but, no error found")
+		}
+		if err.Error() != `failed to sign request: invalid data` {
+			t.Fatalf("Expected error `failed to sign request: invalid data`: but got error %q", err)
+		}
+	})
+}
diff --git a/signer/aws/aws_test.go b/signer/aws/aws_test.go
new file mode 100644
index 0000000..cb75e79
--- /dev/null
+++ b/signer/aws/aws_test.go
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// The OpenSearch Contributors require contributions made to
+// this file be licensed under the Apache-2.0 license or a
+// compatible open source license.
+//
+// Modifications Copyright OpenSearch Contributors. See
+// GitHub history for details.
+
+package aws
+
+import (
+	"bytes"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestV4Signer(t *testing.T) {
+	t.Run("sign request failed due to no region found", func(t *testing.T) {
+		req, err := http.NewRequest(http.MethodGet, "https://localhost:9200", nil)
+		assert.NoError(t, err)
+		region := os.Getenv("AWS_REGION")
+		os.Setenv("AWS_REGION", "")
+		defer func() {
+			os.Setenv("AWS_REGION", region)
+		}()
+		sessionOptions := session.Options{
+			Config: aws.Config{
+				Credentials: credentials.NewStaticCredentials("AKID", "SECRET_KEY", "TOKEN"),
+			},
+		}
+		signer, err := NewSigner(sessionOptions)
+		assert.NoError(t, err)
+		err = signer.SignRequest(req)
+
+		assert.EqualErrorf(
+			t, err, "aws region cannot be empty", "unexpected error")
+	})
+	t.Run("sign request success", func(t *testing.T) {
+		req, err := http.NewRequest(http.MethodGet, "https://localhost:9200", nil)
+		assert.NoError(t, err)
+		region := os.Getenv("AWS_REGION")
+		os.Setenv("AWS_REGION", "us-west-2")
+		defer func() {
+			os.Setenv("AWS_REGION", region)
+		}()
+		sessionOptions := session.Options{
+			Config: aws.Config{
+				Region:      aws.String("us-west-2"),
+				Credentials: credentials.NewStaticCredentials("AKID", "SECRET_KEY", "TOKEN"),
+			},
+		}
+		signer, err := NewSigner(sessionOptions)
+		assert.NoError(t, err)
+		err = signer.SignRequest(req)
+		assert.NoError(t, err)
+		q := req.Header
+		assert.NotEmpty(t, q.Get("Authorization"))
+		assert.NotEmpty(t, q.Get("X-Amz-Date"))
+	})
+
+	t.Run("sign request success with body", func(t *testing.T) {
+		req, err := http.NewRequest(
+			http.MethodPost, "https://localhost:9200",
+			bytes.NewBuffer([]byte(`some data`)))
+		assert.NoError(t, err)
+		region := os.Getenv("AWS_REGION")
+		os.Setenv("AWS_REGION", "us-west-2")
+		defer func() {
+			os.Setenv("AWS_REGION", region)
+		}()
+		sessionOptions := session.Options{
+			Config: aws.Config{
+				Region:      aws.String("us-west-2"),
+				Credentials: credentials.NewStaticCredentials("AKID", "SECRET_KEY", "TOKEN"),
+			},
+		}
+		signer, err := NewSigner(sessionOptions)
+		assert.NoError(t, err)
+		err = signer.SignRequest(req)
+		assert.NoError(t, err)
+		q := req.Header
+		assert.NotEmpty(t, q.Get("Authorization"))
+		assert.NotEmpty(t, q.Get("X-Amz-Date"))
+	})
+}