diff --git a/.chloggen/chaining.yaml b/.chloggen/chaining.yaml new file mode 100644 index 000000000000..e90c0c6ce99e --- /dev/null +++ b/.chloggen/chaining.yaml @@ -0,0 +1,30 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog) +component: extension/headers_setter + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Add support for chaining with other auth extensions via `additional_auth` configuration parameter. This allows combining multiple authentication methods, such as OAuth2 for bearer token authentication and custom headers for additional metadata." + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [43797] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + The `additional_auth` parameter enables the `headers_setter` extension to work in conjunction + with other authentication extensions like `oauth2client`. The additional auth extension is called + first to apply its authentication, then headers_setter adds its configured headers on top. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [user] diff --git a/extension/headerssetterextension/README.md b/extension/headerssetterextension/README.md index cd41aea25d2f..98f20f0d27be 100644 --- a/extension/headerssetterextension/README.md +++ b/extension/headerssetterextension/README.md @@ -23,7 +23,12 @@ header to the value extracted from the context. ## Configuration -The following settings are required: +The following settings are available: + +- `additional_auth` (Optional): The ID of another auth extension to chain with. + When specified, this extension will call the additional auth extension first, + then apply its own headers on top. This allows combining multiple authentication + methods, such as OAuth2 for authorization and custom headers for additional metadata. - `headers`: a list of header configuration objects that specify headers and their value sources. Each configuration object has the following properties: @@ -100,6 +105,65 @@ service: exporters: [ loki ] ``` +## Chaining with other Auth Extensions + +The `headers_setter` extension can be chained with another authentication extension +using the `additional_auth` parameter. This allows combining multiple authentication +methods, such as OAuth2 for bearer token authentication and custom headers for +additional metadata or routing information. + +### Example: Combining OAuth2 and Custom Headers + +```yaml +extensions: + oauth2client: + client_id: someclientid + client_secret: someclientsecret + token_url: https://example.com/oauth2/default/v1/token + scopes: ["api.metrics"] + # The timeout parameter is optional + timeout: 2s + + headers_setter: + # Chain with the oauth2client extension + additional_auth: oauth2client + headers: + - key: X-Scope-OrgID + value: acme-tenant + - key: X-Custom-Header + from_context: custom_metadata + +receivers: + otlp: + protocols: + http: + include_metadata: true + +exporters: + prometheusremotewrite: + endpoint: https://prometheus.example.com/api/v1/write + auth: + # Use headers_setter as the authenticator + # This will apply both OAuth2 and custom headers + authenticator: headers_setter + +service: + extensions: [oauth2client, headers_setter] + pipelines: + metrics: + receivers: [otlp] + exporters: [prometheusremotewrite] +``` + +In this configuration: +1. The `oauth2client` extension provides OAuth2 bearer token authentication +2. The `headers_setter` extension adds custom headers on top of the OAuth2 authentication +3. When the exporter sends data, both authentication methods are applied: + - OAuth2 adds the `Authorization: Bearer ` header + - Headers setter adds `X-Scope-OrgID` and `X-Custom-Header` headers +4. The collector ensures the `oauth2client` extension starts before `headers_setter` + due to the dependency relationship + [batch-processor]: https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/batchprocessor/README.md [batch-processor-preserve-metadata]: https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/batchprocessor/README.md#batching-and-client-metadata diff --git a/extension/headerssetterextension/chaining_test.go b/extension/headerssetterextension/chaining_test.go new file mode 100644 index 000000000000..e2c167bfc162 --- /dev/null +++ b/extension/headerssetterextension/chaining_test.go @@ -0,0 +1,315 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package headerssetterextension + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/extension/extensionauth" + "go.uber.org/zap" + "google.golang.org/grpc/credentials" +) + +// mockAuthExtension is a mock auth extension for testing +type mockAuthExtension struct { + component.StartFunc + component.ShutdownFunc +} + +func (*mockAuthExtension) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + return &mockAuthRoundTripper{base: base, headerKey: "Authorization", headerValue: "Bearer token123"}, nil +} + +func (*mockAuthExtension) PerRPCCredentials() (credentials.PerRPCCredentials, error) { + return &mockPerRPCCredentials{metadata: map[string]string{"authorization": "Bearer token123"}}, nil +} + +// mockAuthRoundTripper adds mock auth headers +type mockAuthRoundTripper struct { + base http.RoundTripper + headerKey string + headerValue string +} + +func (m *mockAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + if req2.Header == nil { + req2.Header = make(http.Header) + } + req2.Header.Set(m.headerKey, m.headerValue) + return m.base.RoundTrip(req2) +} + +// mockPerRPCCredentials provides mock gRPC credentials +type mockPerRPCCredentials struct { + metadata map[string]string +} + +func (m *mockPerRPCCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return m.metadata, nil +} + +func (*mockPerRPCCredentials) RequireTransportSecurity() bool { + return false +} + +// mockHost provides a mock component.Host for testing +type mockHost struct { + component.Host + extensions map[component.ID]component.Component +} + +func (m *mockHost) GetExtensions() map[component.ID]component.Component { + return m.extensions +} + +func TestChainingWithAdditionalAuth_HTTP(t *testing.T) { + // Create auth extension ID + authID := component.MustNewIDWithName("mock_auth", "test") + + // Create headerssetter config with additional_auth + cfg := &Config{ + HeadersConfig: []HeaderConfig{ + { + Key: stringp("X-Custom-Header"), + Value: stringp("custom-value"), + Action: UPSERT, + }, + }, + AdditionalAuth: &authID, + } + + // Create headerssetter extension + ext, err := newHeadersSetterExtension(cfg, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, ext) + + // Create mock host with mock auth extension + host := &mockHost{ + Host: componenttest.NewNopHost(), + extensions: map[component.ID]component.Component{ + authID: &mockAuthExtension{}, + }, + } + + // Start the extension + err = ext.Start(t.Context(), host) + require.NoError(t, err) + + // Get the RoundTripper + captureRT := &requestCaptureRoundTripper{} + rt, err := ext.RoundTripper(captureRT) + require.NoError(t, err) + require.NotNil(t, rt) + + // Create a test request + req, err := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + require.NoError(t, err) + + // Execute the round trip + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + // Verify both auth headers are present in the captured request + require.NotNil(t, captureRT.capturedRequest, "Request should be captured") + assert.Equal(t, "Bearer token123", captureRT.capturedRequest.Header.Get("Authorization"), "OAuth2 header should be present") + assert.Equal(t, "custom-value", captureRT.capturedRequest.Header.Get("X-Custom-Header"), "Custom header should be present") +} + +func TestChainingWithAdditionalAuth_gRPC(t *testing.T) { + // Create auth extension ID + authID := component.MustNewIDWithName("mock_auth", "test") + + // Create headerssetter config with additional_auth + cfg := &Config{ + HeadersConfig: []HeaderConfig{ + { + Key: stringp("x-custom-header"), + Value: stringp("custom-value"), + Action: UPSERT, + }, + }, + AdditionalAuth: &authID, + } + + // Create headerssetter extension + ext, err := newHeadersSetterExtension(cfg, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, ext) + + // Create mock host with mock auth extension + host := &mockHost{ + Host: componenttest.NewNopHost(), + extensions: map[component.ID]component.Component{ + authID: &mockAuthExtension{}, + }, + } + + // Start the extension + err = ext.Start(t.Context(), host) + require.NoError(t, err) + + // Get the PerRPCCredentials + creds, err := ext.PerRPCCredentials() + require.NoError(t, err) + require.NotNil(t, creds) + + // Get metadata + metadata, err := creds.GetRequestMetadata(t.Context()) + require.NoError(t, err) + + // Verify both auth metadata are present + assert.Equal(t, "Bearer token123", metadata["authorization"], "OAuth2 metadata should be present") + assert.Equal(t, "custom-value", metadata["x-custom-header"], "Custom metadata should be present") +} + +func TestChainingWithMissingAuth(t *testing.T) { + // Create auth extension ID that doesn't exist + authID := component.MustNewIDWithName("missing_auth", "test") + + // Create headerssetter config with additional_auth + cfg := &Config{ + HeadersConfig: []HeaderConfig{ + { + Key: stringp("X-Custom-Header"), + Value: stringp("custom-value"), + Action: UPSERT, + }, + }, + AdditionalAuth: &authID, + } + + // Create headerssetter extension + ext, err := newHeadersSetterExtension(cfg, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, ext) + + // Create mock host with NO auth extension + host := &mockHost{ + Host: componenttest.NewNopHost(), + extensions: map[component.ID]component.Component{}, + } + + // Start the extension + err = ext.Start(t.Context(), host) + require.NoError(t, err) + + // Get the RoundTripper - should fail + captureRT := &requestCaptureRoundTripper{} + _, err = ext.RoundTripper(captureRT) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth extension") + assert.Contains(t, err.Error(), "not found") +} + +func TestWithoutAdditionalAuth(t *testing.T) { + // Create headerssetter config WITHOUT additional_auth + cfg := &Config{ + HeadersConfig: []HeaderConfig{ + { + Key: stringp("X-Custom-Header"), + Value: stringp("custom-value"), + Action: UPSERT, + }, + }, + AdditionalAuth: nil, + } + + // Create headerssetter extension + ext, err := newHeadersSetterExtension(cfg, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, ext) + + // Start should not be called when AdditionalAuth is nil + assert.Nil(t, ext.StartFunc) + + // Get the RoundTripper - should work without starting + captureRT := &requestCaptureRoundTripper{} + rt, err := ext.RoundTripper(captureRT) + require.NoError(t, err) + require.NotNil(t, rt) + + // Create a test request + req, err := http.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + require.NoError(t, err) + + // Execute the round trip + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + // Verify only custom header is present in captured request + require.NotNil(t, captureRT.capturedRequest, "Request should be captured") + assert.Empty(t, captureRT.capturedRequest.Header.Get("Authorization"), "Auth header should not be present") + assert.Equal(t, "custom-value", captureRT.capturedRequest.Header.Get("X-Custom-Header"), "Custom header should be present") +} + +func TestDependencies(t *testing.T) { + authID := component.MustNewIDWithName("oauth2", "test") + + tests := []struct { + name string + additionalAuth *component.ID + expectedDeps []component.ID + }{ + { + name: "with additional auth", + additionalAuth: &authID, + expectedDeps: []component.ID{authID}, + }, + { + name: "without additional auth", + additionalAuth: nil, + expectedDeps: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + HeadersConfig: []HeaderConfig{ + { + Key: stringp("X-Test"), + Value: stringp("test"), + Action: UPSERT, + }, + }, + AdditionalAuth: tt.additionalAuth, + } + + ext, err := newHeadersSetterExtension(cfg, zap.NewNop()) + require.NoError(t, err) + + deps := ext.Dependencies() + assert.Equal(t, tt.expectedDeps, deps) + }) + } +} + +// requestCaptureRoundTripper captures the request for testing +type requestCaptureRoundTripper struct { + capturedRequest *http.Request +} + +func (m *requestCaptureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Capture the request for verification + m.capturedRequest = req + // Just return a dummy response + return &http.Response{ + StatusCode: http.StatusOK, + Request: req, + }, nil +} + +// Ensure interface compliance +var ( + _ extensionauth.HTTPClient = (*mockAuthExtension)(nil) + _ extensionauth.GRPCClient = (*mockAuthExtension)(nil) + _ component.Host = (*mockHost)(nil) +) diff --git a/extension/headerssetterextension/config.go b/extension/headerssetterextension/config.go index caea441f9b5d..4ca3a20ee682 100644 --- a/extension/headerssetterextension/config.go +++ b/extension/headerssetterextension/config.go @@ -6,6 +6,7 @@ package headerssetterextension // import "github.com/open-telemetry/opentelemetr import ( "errors" + "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/config/configopaque" ) @@ -17,7 +18,8 @@ var ( ) type Config struct { - HeadersConfig []HeaderConfig `mapstructure:"headers"` + HeadersConfig []HeaderConfig `mapstructure:"headers"` + AdditionalAuth *component.ID `mapstructure:"additional_auth"` // prevent unkeyed literal initialization _ struct{} diff --git a/extension/headerssetterextension/config_test.go b/extension/headerssetterextension/config_test.go index bddc34363da5..cfe863e3e721 100644 --- a/extension/headerssetterextension/config_test.go +++ b/extension/headerssetterextension/config_test.go @@ -64,6 +64,22 @@ func TestLoadConfig(t *testing.T) { }, }, }, + { + id: component.NewIDWithName(metadata.Type, "2"), + expected: &Config{ + AdditionalAuth: func() *component.ID { + id := component.MustNewID("oauth2client") + return &id + }(), + HeadersConfig: []HeaderConfig{ + { + Key: stringp("X-Custom-Header"), + Value: stringp("custom-value"), + Action: UPSERT, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.id.String(), func(t *testing.T) { diff --git a/extension/headerssetterextension/extension.go b/extension/headerssetterextension/extension.go index 6dc336f20fd4..2c1b34d112bc 100644 --- a/extension/headerssetterextension/extension.go +++ b/extension/headerssetterextension/extension.go @@ -12,6 +12,7 @@ import ( "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/extension" "go.opentelemetry.io/collector/extension/extensionauth" + "go.opentelemetry.io/collector/extension/extensioncapabilities" "go.uber.org/zap" "google.golang.org/grpc/credentials" @@ -25,27 +26,98 @@ type header struct { } var ( - _ extension.Extension = (*headerSetterExtension)(nil) - _ extensionauth.HTTPClient = (*headerSetterExtension)(nil) - _ extensionauth.GRPCClient = (*headerSetterExtension)(nil) + _ extension.Extension = (*headerSetterExtension)(nil) + _ extensionauth.HTTPClient = (*headerSetterExtension)(nil) + _ extensionauth.GRPCClient = (*headerSetterExtension)(nil) + _ extensioncapabilities.Dependent = (*headerSetterExtension)(nil) ) type headerSetterExtension struct { component.StartFunc component.ShutdownFunc - headers []header + headers []header + additionalAuth *component.ID + host component.Host +} + +// Dependencies implements extensioncapabilities.Dependent. +func (h *headerSetterExtension) Dependencies() []component.ID { + if h.additionalAuth == nil { + return nil + } + return []component.ID{*h.additionalAuth} +} + +// Start stores the host for later use in getting the additional auth extension. +func (h *headerSetterExtension) Start(_ context.Context, host component.Host) error { + h.host = host + return nil +} + +// getAdditionalAuthExtension retrieves the configured additional auth extension if present. +// Returns nil if no additional auth is configured. +func (h *headerSetterExtension) getAdditionalAuthExtension() (component.Component, error) { + if h.additionalAuth == nil || h.host == nil { + return nil, nil + } + + ext := h.host.GetExtensions()[*h.additionalAuth] + if ext == nil { + return nil, fmt.Errorf("auth extension %v not found", h.additionalAuth) + } + + return ext, nil } // PerRPCCredentials implements extensionauth.GRPCClient. func (h *headerSetterExtension) PerRPCCredentials() (credentials.PerRPCCredentials, error) { - return &headersPerRPC{headers: h.headers}, nil + var baseCredentials credentials.PerRPCCredentials + + // If additional_auth is configured, chain with it first + ext, err := h.getAdditionalAuthExtension() + if err != nil { + return nil, err + } + + if ext != nil { + if grpcClient, ok := ext.(extensionauth.GRPCClient); ok { + baseCredentials, err = grpcClient.PerRPCCredentials() + if err != nil { + return nil, fmt.Errorf("failed to get PerRPCCredentials from %v: %w", h.additionalAuth, err) + } + } + } + + return &headersPerRPC{ + headers: h.headers, + baseCredentials: baseCredentials, + }, nil } // RoundTripper implements extensionauth.HTTPClient. func (h *headerSetterExtension) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + // If additional_auth is configured, chain with it first + baseRT := base + + ext, err := h.getAdditionalAuthExtension() + if err != nil { + return nil, err + } + + if ext != nil { + // Check if it implements HTTPClient + if httpClient, ok := ext.(extensionauth.HTTPClient); ok { + baseRT, err = httpClient.RoundTripper(base) + if err != nil { + return nil, fmt.Errorf("failed to get RoundTripper from %v: %w", h.additionalAuth, err) + } + } + } + + // Now wrap with our headers return &headersRoundTripper{ - base: base, + base: baseRT, headers: h.headers, }, nil } @@ -101,21 +173,46 @@ func newHeadersSetterExtension(cfg *Config, logger *zap.Logger) (*headerSetterEx headers = append(headers, header{action: a, source: s}) } - return &headerSetterExtension{headers: headers}, nil + ext := &headerSetterExtension{ + headers: headers, + additionalAuth: cfg.AdditionalAuth, + } + + // Enable Start method if additional_auth is configured + if cfg.AdditionalAuth != nil { + ext.StartFunc = ext.Start + } + + return ext, nil } // headersPerRPC is a gRPC credentials.PerRPCCredentials implementation sets // headers with values extracted from provided sources. type headersPerRPC struct { - headers []header + headers []header + baseCredentials credentials.PerRPCCredentials } // GetRequestMetadata returns the request metadata to be used with the RPC. func (h *headersPerRPC) GetRequestMetadata( ctx context.Context, - _ ...string, + uri ...string, ) (map[string]string, error) { - metadata := make(map[string]string, len(h.headers)) + // Start with base credentials if available + metadata := make(map[string]string) + + if h.baseCredentials != nil { + baseMetadata, err := h.baseCredentials.GetRequestMetadata(ctx, uri...) + if err != nil { + return nil, err + } + // Copy base metadata + for k, v := range baseMetadata { + metadata[k] = v + } + } + + // Now apply our headers on top for _, header := range h.headers { value, err := header.source.Get(ctx) if err != nil { @@ -126,10 +223,12 @@ func (h *headersPerRPC) GetRequestMetadata( return metadata, nil } -// RequireTransportSecurity always returns false for this implementation. -// The header setter is not sending auth data, so it should not require -// a transport security. -func (*headersPerRPC) RequireTransportSecurity() bool { +// RequireTransportSecurity returns whether transport security is required. +// If chained with another auth extension, delegate to it. +func (h *headersPerRPC) RequireTransportSecurity() bool { + if h.baseCredentials != nil { + return h.baseCredentials.RequireTransportSecurity() + } return false } diff --git a/extension/headerssetterextension/go.mod b/extension/headerssetterextension/go.mod index 7fd76b6065e6..bebf0bf32e94 100644 --- a/extension/headerssetterextension/go.mod +++ b/extension/headerssetterextension/go.mod @@ -12,6 +12,7 @@ require ( go.opentelemetry.io/collector/confmap/xconfmap v0.140.0 go.opentelemetry.io/collector/extension v1.46.0 go.opentelemetry.io/collector/extension/extensionauth v1.46.0 + go.opentelemetry.io/collector/extension/extensioncapabilities v0.140.0 go.opentelemetry.io/collector/extension/extensiontest v0.140.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 diff --git a/extension/headerssetterextension/go.sum b/extension/headerssetterextension/go.sum index b5dce283a13e..41d023fdb91f 100644 --- a/extension/headerssetterextension/go.sum +++ b/extension/headerssetterextension/go.sum @@ -67,6 +67,8 @@ go.opentelemetry.io/collector/extension v1.46.0 h1:+ATT9ADkMUR0cRH8J53vU9MRJ9Usp go.opentelemetry.io/collector/extension v1.46.0/go.mod h1:/NGiZQFF7hTyfRULTgtYw27cIW8i0hWUTp12lDftZS0= go.opentelemetry.io/collector/extension/extensionauth v1.46.0 h1:JvGu9tp+PIPgvXUSSyKMqShtK44ooK6+FAtpBnvaPPc= go.opentelemetry.io/collector/extension/extensionauth v1.46.0/go.mod h1:6Sh0hqPfPqpg0ErCoNPO/ky2NdfGmUX+G5wekPx7A7U= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.140.0 h1:TX2w5PGNVTHDn6phZb6W897A9h/9gjtxlSF60C5wIYo= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.140.0/go.mod h1:CjrwUex7ImIBBTSB84XujWDdK/u+NTRsd4DTjbHGMck= go.opentelemetry.io/collector/extension/extensiontest v0.140.0 h1:a4ggfsp73GA9oGCxBtmQJE827SRq36E+YQIZ0MGIKVQ= go.opentelemetry.io/collector/extension/extensiontest v0.140.0/go.mod h1:TKR1zB0CtJ3tedNyUUaeCw5O2qPlFNjHKmh2ri53uTU= go.opentelemetry.io/collector/featuregate v1.46.0 h1:z3JlymFdWW6aDo9cYAJ6bCqT+OI2DlurJ9P8HqfuKWQ= diff --git a/extension/headerssetterextension/testdata/config.yaml b/extension/headerssetterextension/testdata/config.yaml index 4762f609d97f..e2384f6e097c 100644 --- a/extension/headerssetterextension/testdata/config.yaml +++ b/extension/headerssetterextension/testdata/config.yaml @@ -15,3 +15,9 @@ headers_setter/1: value: "user_id" - key: User-ID action: delete +headers_setter/2: + additional_auth: oauth2client + headers: + - key: X-Custom-Header + action: upsert + value: custom-value