Skip to content
This repository was archived by the owner on Nov 7, 2025. It is now read-only.

Commit 110df7c

Browse files
hsuanyimieciu
andauthored
Support Client certificate authentication for opensearch/elastic search (#1406)
In addition to username and password, es/os support client-auth based auth. See [here](https://docs.opensearch.org/docs/latest/security/authentication-backends/client-auth/). This pr wants to support that. This change introduces client certificate authentication for Quesm when connecting to Elasticsearch. It does not alter how users authenticate to Quesm. (this pr is to implement request of this #1394) <!-- A note on testing your PR --> <!-- Basic unit test run is executed against each commit in the PR. If you want to run a full integration test suite, you can trigger it by commenting with '/run-integration-tests' or '/run-it' --> --------- Co-authored-by: przemyslaw <[email protected]>
1 parent 6d7bbbe commit 110df7c

File tree

7 files changed

+204
-37
lines changed

7 files changed

+204
-37
lines changed

platform/backend_connectors/elasticsearch_backend_connector.go

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ package backend_connectors
66
import (
77
"bytes"
88
"context"
9-
"crypto/tls"
109
"fmt"
1110
"github.com/QuesmaOrg/quesma/platform/config"
1211
"github.com/QuesmaOrg/quesma/platform/elasticsearch"
@@ -32,32 +31,29 @@ type ElasticsearchBackendConnector struct {
3231

3332
// NewElasticsearchBackendConnector is a constructor which uses old (v1) configuration object
3433
func NewElasticsearchBackendConnector(cfg config.ElasticsearchConfiguration) *ElasticsearchBackendConnector {
34+
client := elasticsearch.NewHttpsClient(&cfg, esRequestTimeout)
3535
conn := &ElasticsearchBackendConnector{
3636
config: cfg,
37-
client: &http.Client{
38-
Transport: &http.Transport{
39-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
40-
},
41-
Timeout: esRequestTimeout,
42-
},
37+
client: client,
4338
}
4439
return conn
4540
}
4641

4742
// NewElasticsearchBackendConnectorFromDbConfig is an alternative constructor which uses the generic database configuration object
4843
func NewElasticsearchBackendConnectorFromDbConfig(cfg config.RelationalDbConfiguration) *ElasticsearchBackendConnector {
44+
esConfig := config.ElasticsearchConfiguration{
45+
Url: cfg.Url,
46+
User: cfg.User,
47+
Password: cfg.Password,
48+
ClientCertPath: cfg.ClientCertPath,
49+
ClientKeyPath: cfg.ClientKeyPath,
50+
CACertPath: cfg.CACertPath,
51+
}
52+
53+
client := elasticsearch.NewHttpsClient(&esConfig, esRequestTimeout)
4954
conn := &ElasticsearchBackendConnector{
50-
config: config.ElasticsearchConfiguration{
51-
Url: cfg.Url,
52-
User: cfg.User,
53-
Password: cfg.Password,
54-
},
55-
client: &http.Client{
56-
Transport: &http.Transport{
57-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
58-
},
59-
Timeout: esRequestTimeout,
60-
},
55+
config: esConfig,
56+
client: client,
6157
}
6258
return conn
6359
}

platform/config/config_v2.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ type RelationalDbConfiguration struct {
9494
ClusterName string `koanf:"clusterName"` // When creating tables by Quesma - they'll use `ON CLUSTER ClusterName` clause
9595
AdminUrl *Url `koanf:"adminUrl"`
9696
DisableTLS bool `koanf:"disableTLS"`
97+
98+
// This supports es backend only.
99+
ClientCertPath string `koanf:"clientCertPath"`
100+
ClientKeyPath string `koanf:"clientKeyPath"`
101+
CACertPath string `koanf:"caCertPath"`
97102
}
98103

99104
func (c *RelationalDbConfiguration) IsEmpty() bool {
@@ -1069,9 +1074,12 @@ func (c *QuesmaNewConfiguration) getRelationalDBBackendConnector() (*BackendConn
10691074
func (c *QuesmaNewConfiguration) getElasticsearchConfig() (ElasticsearchConfiguration, error) {
10701075
if esBackendConn := c.getElasticsearchBackendConnector(); esBackendConn != nil {
10711076
return ElasticsearchConfiguration{
1072-
Url: esBackendConn.Config.Url,
1073-
User: esBackendConn.Config.User,
1074-
Password: esBackendConn.Config.Password,
1077+
Url: esBackendConn.Config.Url,
1078+
User: esBackendConn.Config.User,
1079+
Password: esBackendConn.Config.Password,
1080+
ClientCertPath: esBackendConn.Config.ClientCertPath,
1081+
ClientKeyPath: esBackendConn.Config.ClientKeyPath,
1082+
CACertPath: esBackendConn.Config.CACertPath,
10751083
}, nil
10761084
}
10771085
return ElasticsearchConfiguration{}, fmt.Errorf("elasticsearch backend connector must be configured")

platform/config/elasticsearch_config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ type ElasticsearchConfiguration struct {
77
User string `koanf:"user"`
88
Password string `koanf:"password"`
99
AdminUrl *Url `koanf:"adminUrl"`
10+
11+
ClientCertPath string `koanf:"clientCertPath"`
12+
ClientKeyPath string `koanf:"clientKeyPath"`
13+
CACertPath string `koanf:"caCertPath"`
1014
}

platform/elasticsearch/client.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import (
66
"bytes"
77
"context"
88
"crypto/tls"
9+
"crypto/x509"
910
"fmt"
1011
"github.com/QuesmaOrg/quesma/platform/config"
1112
"github.com/QuesmaOrg/quesma/platform/logger"
1213
"net/http"
14+
"os"
1315
"time"
1416
)
1517

@@ -24,18 +26,53 @@ type SimpleClient struct {
2426
config *config.ElasticsearchConfiguration
2527
}
2628

27-
func NewSimpleClient(configuration *config.ElasticsearchConfiguration) *SimpleClient {
28-
client := &http.Client{
29+
// NewHttpsClient should be merged with NewSimpleClient at some point -> TODO!
30+
func NewHttpsClient(configuration *config.ElasticsearchConfiguration, timeout time.Duration) *http.Client {
31+
tlsConfig := &tls.Config{
32+
MinVersion: tls.VersionTLS12,
33+
InsecureSkipVerify: true,
34+
}
35+
36+
if configuration.CACertPath != "" {
37+
caCert, err := os.ReadFile(configuration.CACertPath)
38+
if err != nil {
39+
logger.Warn().Msgf("failed to read CA certificate: %v. Fallback to skipping tls.", err)
40+
} else {
41+
caCertPool := x509.NewCertPool()
42+
if !caCertPool.AppendCertsFromPEM(caCert) {
43+
logger.Warn().Msgf("failed to append CA certificate: %v. Fallback to skipping tls.", err)
44+
} else {
45+
tlsConfig.RootCAs = caCertPool
46+
tlsConfig.InsecureSkipVerify = false
47+
}
48+
}
49+
}
50+
51+
if configuration.ClientCertPath != "" && configuration.ClientKeyPath != "" {
52+
cert, err := tls.LoadX509KeyPair(configuration.ClientCertPath, configuration.ClientKeyPath)
53+
if err != nil {
54+
logger.Warn().Msgf("failed to load client certificate/key: %v. Fallback to certificate-less client.", err)
55+
} else {
56+
tlsConfig.Certificates = []tls.Certificate{cert}
57+
}
58+
}
59+
60+
return &http.Client{
2961
Transport: &http.Transport{
30-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
62+
TLSClientConfig: tlsConfig,
3163
},
32-
Timeout: esRequestTimeout,
64+
Timeout: timeout,
3365
}
66+
}
67+
68+
func NewSimpleClient(configuration *config.ElasticsearchConfiguration) *SimpleClient {
69+
client := NewHttpsClient(configuration, esRequestTimeout)
3470
return &SimpleClient{
3571
client: client,
3672
config: configuration,
3773
}
3874
}
75+
3976
func (es *SimpleClient) Request(ctx context.Context, method, endpoint string, body []byte) (*http.Response, error) {
4077
return es.doRequest(ctx, method, endpoint, body, nil)
4178
}

platform/elasticsearch/client_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ package elasticsearch
44

55
import (
66
"context"
7+
"crypto/rand"
8+
"crypto/rsa"
9+
"crypto/x509"
10+
"crypto/x509/pkix"
11+
"encoding/pem"
712
"github.com/QuesmaOrg/quesma/platform/config"
813
"github.com/stretchr/testify/assert"
914
"io"
15+
"math/big"
1016
"net/http"
1117
"net/http/httptest"
1218
"net/url"
19+
"os"
1320
"testing"
21+
"time"
1422
)
1523

1624
const testPayload = "{'test': 'test'}"
@@ -108,3 +116,121 @@ func TestSimpleClient_RequestWithHeaders_OverwritesContentType(t *testing.T) {
108116
assert.NoError(t, err)
109117
assert.Equal(t, resp.StatusCode, http.StatusOK)
110118
}
119+
120+
func writeTempPEM(t *testing.T, prefix string, pemBytes []byte) string {
121+
t.Helper()
122+
123+
tmpFile, err := os.CreateTemp("", prefix+"-*.pem")
124+
if err != nil {
125+
t.Fatalf("failed to create temp file: %v", err)
126+
}
127+
defer tmpFile.Close()
128+
129+
if _, err := tmpFile.Write(pemBytes); err != nil {
130+
t.Fatalf("failed to write to temp file: %v", err)
131+
}
132+
133+
t.Cleanup(func() {
134+
os.Remove(tmpFile.Name())
135+
})
136+
137+
return tmpFile.Name()
138+
}
139+
140+
func generateTestCertAndKey(t *testing.T) (certPath, keyPath string) {
141+
t.Helper()
142+
143+
private, err := rsa.GenerateKey(rand.Reader, 2048)
144+
if err != nil {
145+
t.Fatalf("failed to generate private key: %v", err)
146+
}
147+
148+
template := x509.Certificate{
149+
SerialNumber: big.NewInt(1),
150+
Subject: pkix.Name{
151+
Organization: []string{"Test Certificate"},
152+
},
153+
NotBefore: time.Now(),
154+
NotAfter: time.Now().Add(365 * 24 * time.Hour),
155+
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
156+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
157+
BasicConstraintsValid: true,
158+
IsCA: true,
159+
}
160+
161+
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &private.PublicKey, private)
162+
if err != nil {
163+
t.Fatalf("failed to create certificate: %v", err)
164+
}
165+
166+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
167+
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(private)})
168+
169+
certPath = writeTempPEM(t, "test-cert", certPEM)
170+
keyPath = writeTempPEM(t, "test-key", keyPEM)
171+
return certPath, keyPath
172+
}
173+
174+
func TestNewHttpsClient_NoCerts(t *testing.T) {
175+
conf := &config.ElasticsearchConfiguration{}
176+
client := NewHttpsClient(conf, 5*time.Second)
177+
178+
tlsConfig := client.Transport.(*http.Transport).TLSClientConfig
179+
assert.Nil(t, tlsConfig.RootCAs)
180+
assert.Nil(t, tlsConfig.Certificates)
181+
assert.True(t, tlsConfig.InsecureSkipVerify)
182+
}
183+
184+
func TestNewHttpsClient_WithCACert(t *testing.T) {
185+
caPath, _ := generateTestCertAndKey(t)
186+
187+
conf := &config.ElasticsearchConfiguration{
188+
CACertPath: caPath,
189+
}
190+
client := NewHttpsClient(conf, 5*time.Second)
191+
192+
tlsConfig := client.Transport.(*http.Transport).TLSClientConfig
193+
assert.NotNil(t, tlsConfig.RootCAs)
194+
assert.Nil(t, tlsConfig.Certificates)
195+
assert.False(t, tlsConfig.InsecureSkipVerify)
196+
}
197+
198+
func TestNewHttpsClient_WithClientCert(t *testing.T) {
199+
certPath, keyPath := generateTestCertAndKey(t) // real cert and key
200+
201+
conf := &config.ElasticsearchConfiguration{
202+
ClientCertPath: certPath,
203+
ClientKeyPath: keyPath,
204+
}
205+
client := NewHttpsClient(conf, 5*time.Second)
206+
207+
tlsConfig := client.Transport.(*http.Transport).TLSClientConfig
208+
assert.Nil(t, tlsConfig.RootCAs)
209+
assert.NotNil(t, tlsConfig.Certificates)
210+
assert.True(t, tlsConfig.InsecureSkipVerify)
211+
}
212+
213+
func TestNewHttpsClient_InvalidCAPath(t *testing.T) {
214+
conf := &config.ElasticsearchConfiguration{
215+
CACertPath: "/nonexistent/file.pem",
216+
}
217+
client := NewHttpsClient(conf, 5*time.Second)
218+
219+
tlsConfig := client.Transport.(*http.Transport).TLSClientConfig
220+
assert.Nil(t, tlsConfig.RootCAs)
221+
assert.Nil(t, tlsConfig.Certificates)
222+
assert.True(t, tlsConfig.InsecureSkipVerify)
223+
}
224+
225+
func TestNewHttpsClient_InvalidClientCertPath(t *testing.T) {
226+
conf := &config.ElasticsearchConfiguration{
227+
ClientCertPath: "/invalid/cert.pem",
228+
ClientKeyPath: "/invalid/key.pem",
229+
}
230+
client := NewHttpsClient(conf, 5*time.Second)
231+
232+
tlsConfig := client.Transport.(*http.Transport).TLSClientConfig
233+
assert.Nil(t, tlsConfig.RootCAs)
234+
assert.Nil(t, tlsConfig.Certificates)
235+
assert.True(t, tlsConfig.InsecureSkipVerify)
236+
}

platform/frontend_connectors/dispatcher.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package frontend_connectors
55
import (
66
"bytes"
77
"context"
8-
"crypto/tls"
98
"errors"
109
"fmt"
1110
"github.com/QuesmaOrg/quesma/platform/clickhouse"
@@ -32,6 +31,8 @@ import (
3231
"time"
3332
)
3433

34+
const httpClientTimeout = time.Minute
35+
3536
func responseFromElastic(ctx context.Context, elkResponse *http.Response, w http.ResponseWriter) {
3637
if id, ok := ctx.Value(tracing.RequestIdCtxKey).(string); ok {
3738
logger.Debug().Str(logger.RID, id).Msgf("responding from Elasticsearch, status_code=%d", elkResponse.StatusCode)
@@ -90,14 +91,9 @@ func (r *Dispatcher) SetDependencies(deps quesma_api.Dependencies) {
9091
r.debugInfoCollector = deps.DebugInfoCollector()
9192
r.phoneHomeAgent = deps.PhoneHomeAgent()
9293
}
94+
9395
func NewDispatcher(config *config.QuesmaConfiguration) *Dispatcher {
94-
tr := &http.Transport{
95-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
96-
}
97-
client := &http.Client{
98-
Transport: tr,
99-
Timeout: time.Minute, // should be more configurable, 30s is Kibana default timeout
100-
}
96+
client := elasticsearch.NewHttpsClient(&config.Elasticsearch, httpClientTimeout)
10197
requestProcessors := quesma_api.ProcessorChain{}
10298
requestProcessors = append(requestProcessors, quesma_api.NewTraceIdPreprocessor())
10399

platform/telemetry/phone_home.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ type agent struct {
8787
recent diag.PhoneHomeStats
8888
telemetryEndpoint *config.Url
8989

90-
httpClient *http.Client
90+
httpClient *http.Client
91+
elasticsearchHttpClient *http.Client
9192
}
9293

9394
func generateInstanceID() string {
@@ -114,9 +115,7 @@ func NewPhoneHomeAgent(configuration *config.QuesmaConfiguration, clickHouseDb q
114115
// TODO
115116
// this is a question, maybe we should inherit context from the caller
116117
// maybe the main function should be the one to cancel the context
117-
118118
ctx, cancel := context.WithCancel(context.Background())
119-
120119
return &agent{
121120
ctx: ctx,
122121
cancel: cancel,
@@ -141,6 +140,7 @@ func NewPhoneHomeAgent(configuration *config.QuesmaConfiguration, clickHouseDb q
141140
},
142141
Timeout: time.Minute,
143142
},
143+
elasticsearchHttpClient: elasticsearch.NewHttpsClient(&configuration.Elasticsearch, time.Minute),
144144
}
145145
}
146146

@@ -395,7 +395,7 @@ func (a *agent) callElastic(ctx context.Context, url *url.URL, response interfac
395395
return err
396396
}
397397

398-
resp, err := a.httpClient.Do(request)
398+
resp, err := a.elasticsearchHttpClient.Do(request)
399399
if err != nil {
400400
logger.Error().Err(err).Msg("Error getting info from elasticsearch. ")
401401
return err

0 commit comments

Comments
 (0)