Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion opensearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ type Config struct {
Username string // Username for HTTP Basic Authentication.
// Password for HTTP Basic Authentication.
Password string // #nosec G117
// Api key based authentication
APIKey string // #nosec G117

Header http.Header // Global HTTP request header.

Expand Down Expand Up @@ -446,6 +448,7 @@ func NewClient(cfg Config) (*Client, error) {
URLs: urls,
Username: cfg.Username,
Password: cfg.Password,
APIKey: cfg.APIKey,

Header: cfg.Header,
CACert: cfg.CACert,
Expand Down Expand Up @@ -597,7 +600,7 @@ func configKey(cfg Config) (ttlcache.Key, bool) {
for _, a := range cfg.Addresses {
b.String(a)
}
b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password)
b.String(configKeyFieldSep).String(cfg.Username).String(cfg.Password).String(cfg.APIKey)

// Header: sort keys and values for determinism.
keys := make([]string, 0, len(cfg.Header))
Expand Down
26 changes: 25 additions & 1 deletion opensearch_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,27 @@
require.Equal(t, "http://admin:admin@localhost:8080", u)
})

t.Run("With APIKey sends correct Authorization header", func(t *testing.T) {
const key = "dGVzdGlkOnRlc3RrZXk="
var gotAuth string
c, err := NewClient(Config{
Addresses: []string{"http://localhost:9200"},
APIKey: key,
Transport: mockhttp.NewRoundTripFunc(t, func(req *http.Request) (*http.Response, error) {
gotAuth = req.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}),
})
require.NoError(t, err)
req, _ := http.NewRequest(http.MethodGet, "/", nil)
_, _ = c.Stream(req)

Check failure on line 190 in opensearch_internal_test.go

View workflow job for this annotation

GitHub Actions / Lint (core plugins plugin_security plugin_index_management)

response body must be closed (bodyclose)
require.Equal(t, "ApiKey "+key, gotAuth)
})

t.Run("With DiscoverNodes on start", func(t *testing.T) {
c, err := NewClient(
Config{
Expand Down Expand Up @@ -765,6 +786,9 @@
Config{Header: http.Header{"a": {"b", "c", "d"}}},
false,
},
{"diff api key", Config{APIKey: "key-a"}, Config{APIKey: "key-b"}, false},
{"same api key", Config{APIKey: "key-a"}, Config{APIKey: "key-a"}, true},
{"api key vs empty", Config{APIKey: "key-a"}, Config{}, false},
{"diff retry-on-status", Config{RetryOnStatus: []int{502}}, Config{RetryOnStatus: []int{503}}, false},
{"same retry-on-status", Config{RetryOnStatus: []int{502, 503}}, Config{RetryOnStatus: []int{502, 503}}, true},
{
Expand Down Expand Up @@ -827,7 +851,7 @@
// TestConfigKey_FieldGuard fails loudly when Config grows a field without a
// corresponding update to configKey, preventing a silent cache-key collision.
func TestConfigKey_FieldGuard(t *testing.T) {
const knownFieldCount = 48
const knownFieldCount = 49
got := reflect.TypeFor[Config]().NumField()
require.Equal(t, knownFieldCount, got,
"Config field count changed: audit configKey for the new field, then update knownFieldCount")
Expand Down
9 changes: 9 additions & 0 deletions opensearchtransport/opensearchtransport.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ type Config struct {
Username string
// Password for HTTP Basic Authentication.
Password string // #nosec G117
// ApiKey for HTTP
APIKey string // #nosec G117

Header http.Header
CACert []byte
Expand Down Expand Up @@ -449,6 +451,7 @@ type Transport struct {
urls []*url.URL
username string
password string
apiKey string
header http.Header
userAgent string

Expand Down Expand Up @@ -934,6 +937,7 @@ func New(cfg Config) (*Transport, error) {
urls: cfg.URLs,
username: cfg.Username,
password: cfg.Password,
apiKey: cfg.APIKey,
header: cfg.Header,

signer: cfg.Signer,
Expand Down Expand Up @@ -2043,6 +2047,11 @@ func (c *Transport) setReqAuth(u *url.URL, req *http.Request) {
return
}

if c.apiKey != "" {
req.Header.Set("Authorization", "ApiKey "+c.apiKey)
return
}

if c.username != "" && c.password != "" {
req.SetBasicAuth(c.username, c.password)
return
Expand Down
72 changes: 72 additions & 0 deletions opensearchtransport/opensearchtransport_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ package opensearchtransport_test

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -271,3 +272,74 @@ func TestTransportCompression(t *testing.T) {
res.Body.Close()
}
}

func TestTransportAPIKeyAuth(t *testing.T) {
// API key creation requires the security plugin, which is only present in
// secure (HTTPS) integration environments.
if !testutil.IsSecure(t) {
t.Skip("TestTransportAPIKeyAuth requires SECURE_INTEGRATION=true (security plugin)")
}

tptestutil.SkipIfVersion(t, "<", "2.0.0", "API key support requires OpenSearch 2.x+")

config := testutil.ClientConfig(t)
u := mockhttp.GetOpenSearchURL(t)

// Step 1: create an API key using admin credentials.
adminTP, err := opensearchtransport.New(opensearchtransport.Config{
URLs: []*url.URL{u},
Username: config.Client.Username,
Password: config.Client.Password,
Transport: config.Client.Transport,
})
if err != nil {
t.Fatalf("failed to create admin transport: %s", err)
}

createBody := strings.NewReader(`{"name":"go-client-test-key"}`)
createReq, _ := http.NewRequest(http.MethodPost, "/_security/api_key", createBody)
createReq.Header.Set("Content-Type", "application/json")
createRes, err := adminTP.Stream(createReq)
if err != nil {
t.Fatalf("failed to call /_security/api_key: %s", err)
}
defer createRes.Body.Close()

if createRes.StatusCode != http.StatusOK {
body, _ := io.ReadAll(createRes.Body)
t.Fatalf("unexpected status %d creating API key: %s", createRes.StatusCode, body)
}

var keyResp struct {
Encoded string `json:"encoded"`
}
if err := json.NewDecoder(createRes.Body).Decode(&keyResp); err != nil {
t.Fatalf("failed to decode API key response: %s", err)
}
if keyResp.Encoded == "" {
t.Fatal("API key response missing 'encoded' field")
}

// Step 2: build a transport that authenticates solely with the API key.
keyTP, err := opensearchtransport.New(opensearchtransport.Config{
URLs: []*url.URL{u},
APIKey: keyResp.Encoded,
Transport: config.Client.Transport,
})
if err != nil {
t.Fatalf("failed to create API key transport: %s", err)
}

// Step 3: verify the API key grants access to GET /.
infoReq, _ := http.NewRequest(http.MethodGet, "/", nil)
infoRes, err := keyTP.Stream(infoReq)
if err != nil {
t.Fatalf("GET / with API key failed: %s", err)
}
defer infoRes.Body.Close()
io.ReadAll(infoRes.Body) //nolint:errcheck // We dont need values here, just run this as side effect.

if infoRes.StatusCode != http.StatusOK {
t.Errorf("expected 200 from GET / with API key, got %d", infoRes.StatusCode)
}
}
51 changes: 51 additions & 0 deletions opensearchtransport/transport_coverage_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,57 @@
_, _, ok := req.BasicAuth()
require.False(t, ok)
})

t.Run("API key sets Authorization header", func(t *testing.T) {
t.Parallel()
dummyApiKey := "dGVzdGlkOnRlc3RrZXk="

Check failure on line 170 in opensearchtransport/transport_coverage_internal_test.go

View workflow job for this annotation

GitHub Actions / Lint (core plugins plugin_security plugin_index_management)

G101: Potential hardcoded credentials (gosec)
c := &Transport{apiKey: dummyApiKey}
u, _ := url.Parse("https://node1:9200")
req, _ := http.NewRequest(http.MethodGet, "/", nil)
c.setReqAuth(u, req)

require.Equal(t, fmt.Sprintf("ApiKey %s", dummyApiKey), req.Header.Get("Authorization"))
})

t.Run("API key takes precedence over username/password", func(t *testing.T) {
t.Parallel()
dummyApiKey := "dGVzdGlkOnRlc3RrZXk="

Check failure on line 181 in opensearchtransport/transport_coverage_internal_test.go

View workflow job for this annotation

GitHub Actions / Lint (core plugins plugin_security plugin_index_management)

G101: Potential hardcoded credentials (gosec)
c := &Transport{apiKey: dummyApiKey, username: "admin", password: "secret"}
u, _ := url.Parse("https://node1:9200")
req, _ := http.NewRequest(http.MethodGet, "/", nil)
c.setReqAuth(u, req)

require.Equal(t, fmt.Sprintf("ApiKey %s", dummyApiKey), req.Header.Get("Authorization"))
_, _, basicOK := req.BasicAuth()
require.False(t, basicOK)
})

t.Run("URL userinfo takes precedence over API key", func(t *testing.T) {
t.Parallel()
dummyApiKey := "dGVzdGlkOnRlc3RrZXk="

Check failure on line 194 in opensearchtransport/transport_coverage_internal_test.go

View workflow job for this annotation

GitHub Actions / Lint (core plugins plugin_security plugin_index_management)

G101: Potential hardcoded credentials (gosec)
c := &Transport{apiKey: dummyApiKey}
u, _ := url.Parse("https://url-user:url-pass@node1:9200")
req, _ := http.NewRequest(http.MethodGet, "/", nil)
c.setReqAuth(u, req)

user, pass, ok := req.BasicAuth()
require.True(t, ok)
require.Equal(t, "url-user", user)
require.Equal(t, "url-pass", pass)
})

t.Run("existing Authorization header not overwritten by API key", func(t *testing.T) {
t.Parallel()
dummyApiKey := "dGVzdGlkOnRlc3RrZXk="
c := &Transport{apiKey: dummyApiKey}
u, _ := url.Parse("https://node1:9200")
req, _ := http.NewRequest(http.MethodGet, "/", nil)
existingToken := "some-random-token"
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", existingToken))
c.setReqAuth(u, req)

require.Equal(t, fmt.Sprintf("Bearer %s", existingToken), req.Header.Get("Authorization"))
})
}

func TestSignRequest_Coverage(t *testing.T) {
Expand Down
Loading