Skip to content

Commit 857b9bc

Browse files
committed
feat: add support for interceptors in Elasticsearch client
1 parent a440b0c commit 857b9bc

File tree

12 files changed

+1113
-0
lines changed

12 files changed

+1113
-0
lines changed

_examples/interceptor/Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
GO_TEST_CMD = $(if $(shell which richgo),richgo test,go test)
2+
export ELASTICSEARCH_URL=http://elastic:elastic@localhost:9200
3+
4+
test: ## Run tests
5+
go run ./cmd/auth_provider/main.go
6+
go run ./cmd/context_auth/main.go
7+
go run ./cmd/custom_auth/main.go
8+
go run ./cmd/custom_observability/main.go
9+
10+
setup:
11+
12+
.PHONY: test setup

_examples/interceptor/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Example: Interceptors
2+
3+
This example demonstrates how to use Interceptors to modify HTTP requests before they are sent to Elasticsearch.
4+
5+
Interceptors wrap the HTTP round-trip and can inspect or modify requests and responses.
6+
They are configured via the `elasticsearch.Config.Interceptors` field:
7+
8+
```go
9+
es, _ := elasticsearch.NewClient(elasticsearch.Config{
10+
Interceptors: []elastictransport.InterceptorFunc{
11+
func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
12+
return func(req *http.Request) (*http.Response, error) {
13+
// Modify request before sending
14+
return next(req)
15+
}
16+
},
17+
},
18+
})
19+
```
20+
21+
## Dynamic Auth Provider
22+
23+
The [`cmd/auth_provider/main.go`](cmd/auth_provider/main.go) example demonstrates how to dynamically inject authentication credentials into requests.
24+
25+
This pattern is useful for scenarios where credentials may change at runtime, such as token refresh or credential rotation.
26+
27+
```bash
28+
go run cmd/auth_provider/main.go
29+
```
30+
31+
## Context-Based Auth Override
32+
33+
The [`cmd/context_auth/main.go`](cmd/context_auth/main.go) example demonstrates how to override authentication credentials on a per-request basis using `context.Context`.
34+
35+
This pattern is useful for multi-tenant applications or impersonation scenarios where different requests need different credentials.
36+
37+
```bash
38+
go run cmd/context_auth/main.go
39+
```
40+
41+
## Custom Auth (Kerberos/SPNEGO)
42+
43+
The [`cmd/custom_auth/main.go`](cmd/custom_auth/main.go) example demonstrates how to implement Kerberos/SPNEGO authentication with challenge-response handling.
44+
45+
The interceptor handles 401 responses with `WWW-Authenticate: Negotiate` by obtaining a token and retrying the request.
46+
47+
> **Note:** This example uses a mock implementation. In production, you would use a Kerberos library like [gokrb5](https://github.com/jcmturner/gokrb5) to obtain service tickets.
48+
49+
```bash
50+
go run cmd/custom_auth/main.go
51+
```
52+
53+
## Custom Observability
54+
55+
The [`cmd/custom_observability/main.go`](cmd/custom_observability/main.go) example demonstrates how to add custom observability to Elasticsearch requests using OpenTelemetry.
56+
57+
It shows three interceptors for:
58+
59+
* **Logging**: Request/response details using `slog`
60+
* **Metrics**: Request counter and duration histogram
61+
* **Tracing**: Distributed tracing with spans
62+
63+
> **Note:** The client has built-in observability functionality. Prefer using the built-in options where possible.
64+
65+
```bash
66+
go run cmd/custom_observability/main.go
67+
```
68+
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
// This example demonstrates how to use Interceptors to dynamically
19+
// inject authentication credentials into requests.
20+
//
21+
// Interceptors allow you to modify requests before they are sent,
22+
// making them ideal for scenarios where credentials may change at
23+
// runtime (e.g., token refresh, credential rotation).
24+
package main
25+
26+
import (
27+
"fmt"
28+
"log/slog"
29+
"net/http"
30+
"sync"
31+
32+
"github.com/elastic/elastic-transport-go/v8/elastictransport"
33+
"github.com/elastic/go-elasticsearch/v9"
34+
"github.com/elastic/go-elasticsearch/v9/_examples/interceptor/internal/fake"
35+
"github.com/elastic/go-elasticsearch/v9/_examples/interceptor/internal/redact"
36+
)
37+
38+
func main() {
39+
// Start a fake Elasticsearch server that logs incoming auth credentials
40+
srv := fake.NewServer(
41+
fake.WithLogFn(func(r *http.Request) {
42+
username, password, _ := redact.BasicAuth(r)
43+
slog.Info("server received request",
44+
slog.String("method", r.Method),
45+
slog.String("path", r.URL.Path),
46+
slog.String("username", username),
47+
slog.String("password", password),
48+
)
49+
}),
50+
fake.WithStatusCode(http.StatusOK),
51+
fake.WithResponseBody([]byte(`{"cluster_name":"example"}`)),
52+
fake.WithHeaders(func(h http.Header) {
53+
h.Set("X-Elastic-Product", "Elasticsearch")
54+
h.Set("Content-Type", "application/json")
55+
}),
56+
)
57+
defer srv.Close()
58+
59+
// Create a credential provider that can be updated at runtime
60+
authProvider := NewCredentialProvider("user1", "password1")
61+
62+
// Create an Elasticsearch client with a custom auth interceptor
63+
es, err := elasticsearch.NewClient(elasticsearch.Config{
64+
Addresses: []string{srv.URL()},
65+
Interceptors: []elastictransport.InterceptorFunc{
66+
DynamicAuthInterceptor(authProvider),
67+
},
68+
})
69+
if err != nil {
70+
panic(err)
71+
}
72+
73+
// First request uses initial credentials
74+
fmt.Println(">>> Sending request with initial credentials (user1)")
75+
_, _ = es.Info()
76+
77+
// Update credentials (simulating credential rotation)
78+
fmt.Println("\n>>> Rotating credentials to (user2)")
79+
authProvider.Update("user2", "password2")
80+
81+
// Second request automatically uses the new credentials
82+
fmt.Println("\n>>> Sending request with rotated credentials (user2)")
83+
_, _ = es.Info()
84+
}
85+
86+
// DynamicAuthInterceptor creates an interceptor that injects BasicAuth
87+
// credentials from a CredentialProvider into each request.
88+
func DynamicAuthInterceptor(provider *CredentialProvider) elastictransport.InterceptorFunc {
89+
return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
90+
return func(req *http.Request) (*http.Response, error) {
91+
username, password := provider.Get()
92+
req.SetBasicAuth(username, password)
93+
return next(req)
94+
}
95+
}
96+
}
97+
98+
// CredentialProvider holds credentials that can be safely updated at runtime.
99+
type CredentialProvider struct {
100+
mu sync.RWMutex
101+
username string
102+
password string
103+
}
104+
105+
func NewCredentialProvider(username, password string) *CredentialProvider {
106+
return &CredentialProvider{username: username, password: password}
107+
}
108+
109+
func (p *CredentialProvider) Update(username, password string) {
110+
p.mu.Lock()
111+
defer p.mu.Unlock()
112+
p.username = username
113+
p.password = password
114+
}
115+
116+
func (p *CredentialProvider) Get() (username, password string) {
117+
p.mu.RLock()
118+
defer p.mu.RUnlock()
119+
return p.username, p.password
120+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
// This example demonstrates how to use Interceptors to override
19+
// authentication credentials on a per-request basis using context.Context.
20+
//
21+
// This pattern is useful when different requests need different credentials,
22+
// such as multi-tenant applications or impersonation scenarios.
23+
package main
24+
25+
import (
26+
"context"
27+
"fmt"
28+
"log/slog"
29+
"net/http"
30+
31+
"github.com/elastic/elastic-transport-go/v8/elastictransport"
32+
"github.com/elastic/go-elasticsearch/v9"
33+
"github.com/elastic/go-elasticsearch/v9/_examples/interceptor/internal/fake"
34+
"github.com/elastic/go-elasticsearch/v9/_examples/interceptor/internal/redact"
35+
)
36+
37+
func main() {
38+
// Start a fake Elasticsearch server that logs incoming auth credentials
39+
srv := fake.NewServer(
40+
fake.WithLogFn(func(r *http.Request) {
41+
username, password, _ := redact.BasicAuth(r)
42+
slog.Info("server received request",
43+
slog.String("method", r.Method),
44+
slog.String("path", r.URL.Path),
45+
slog.String("username", username),
46+
slog.String("password", password),
47+
)
48+
}),
49+
fake.WithStatusCode(http.StatusOK),
50+
fake.WithResponseBody([]byte(`{"cluster_name":"example"}`)),
51+
fake.WithHeaders(func(h http.Header) {
52+
h.Set("X-Elastic-Product", "Elasticsearch")
53+
h.Set("Content-Type", "application/json")
54+
}),
55+
)
56+
defer srv.Close()
57+
58+
// Create an Elasticsearch client with default credentials and context auth interceptor
59+
es, err := elasticsearch.NewClient(elasticsearch.Config{
60+
Addresses: []string{srv.URL()},
61+
Username: "default_user",
62+
Password: "default_password",
63+
Interceptors: []elastictransport.InterceptorFunc{
64+
ContextAuthInterceptor(),
65+
},
66+
})
67+
if err != nil {
68+
panic(err)
69+
}
70+
71+
// Request without context override uses default credentials
72+
fmt.Println(">>> Sending request with default credentials")
73+
_, _ = es.Info()
74+
75+
// Request with context override uses the specified credentials
76+
fmt.Println("\n>>> Sending request with context override (tenant_a)")
77+
ctx := WithBasicAuth(context.Background(), "tenant_a", "tenant_a_secret")
78+
_, _ = es.Info(es.Info.WithContext(ctx))
79+
80+
// Another request with different context credentials
81+
fmt.Println("\n>>> Sending request with context override (tenant_b)")
82+
ctx = WithBasicAuth(context.Background(), "tenant_b", "tenant_b_secret")
83+
_, _ = es.Info(es.Info.WithContext(ctx))
84+
85+
// Request without context override still uses default credentials
86+
fmt.Println("\n>>> Sending request with default credentials again")
87+
_, _ = es.Info()
88+
}
89+
90+
// basicAuthKey is the context key for storing basic auth credentials.
91+
type basicAuthKey struct{}
92+
93+
type basicAuthValue struct {
94+
username string
95+
password string
96+
}
97+
98+
// WithBasicAuth returns a context with basic auth credentials attached.
99+
// Use this to override the default client credentials for a specific request.
100+
func WithBasicAuth(ctx context.Context, username, password string) context.Context {
101+
return context.WithValue(ctx, basicAuthKey{}, basicAuthValue{username, password})
102+
}
103+
104+
// ContextAuthInterceptor creates an interceptor that overrides BasicAuth
105+
// credentials if they are present in the request's context.
106+
// If no credentials are in the context, the request proceeds unchanged.
107+
func ContextAuthInterceptor() elastictransport.InterceptorFunc {
108+
return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
109+
return func(req *http.Request) (*http.Response, error) {
110+
if auth, ok := req.Context().Value(basicAuthKey{}).(basicAuthValue); ok {
111+
req.SetBasicAuth(auth.username, auth.password)
112+
}
113+
return next(req)
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)