diff --git a/.chloggen/feat_basicauth-aws-secrets-manager.yaml b/.chloggen/feat_basicauth-aws-secrets-manager.yaml new file mode 100644 index 0000000000000..e9fc1dccb9ba7 --- /dev/null +++ b/.chloggen/feat_basicauth-aws-secrets-manager.yaml @@ -0,0 +1,32 @@ +# 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/basicauth + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add AWS Secrets Manager support for client credential rotation without restarting the collector. + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [48277] + +# (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 new `client_auth.aws_secrets_manager` option polls a JSON secret from AWS Secrets Manager + at a configurable interval (default: 1h) and swaps credentials in place when a rotation is + detected. Key names default to `username` and `password`, matching AWS's own RDS/Redshift/ + DocumentDB rotation templates, and are configurable via `username_key` and `password_key`. + On poll failure the extension logs a warning and retains the last known good credentials. + +# 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/basicauthextension/README.md b/extension/basicauthextension/README.md index b65ac3d92f5ad..9fe3f3f912862 100644 --- a/extension/basicauthextension/README.md +++ b/extension/basicauthextension/README.md @@ -31,6 +31,7 @@ The following are the configuration options: - `client_auth.username_file`: Path to a file containing the username. If set, takes precedence over `username`. The file is watched for changes, allowing rotation without restarting the collector. - `client_auth.password`: Password to use for client authentication. - `client_auth.password_file`: Path to a file containing the password. If set, takes precedence over `password`. The file is watched for changes, allowing rotation without restarting the collector. +- `client_auth.aws_secrets_manager`: Fetch credentials from AWS Secrets Manager. Mutually exclusive with the inline/file options above. See [AWS Secrets Manager](#aws-secrets-manager) for details. To configure the extension as a server authenticator, either one of `htpasswd.file` or `htpasswd.inline` has to be set. If both are configured, `htpasswd.inline` credentials take precedence. @@ -58,6 +59,16 @@ extensions: username_file: /etc/secrets/username password_file: /etc/secrets/password + # AWS Secrets Manager (polled for changes, enabling rotation without restart) + basicauth/client_from_aws: + client_auth: + aws_secrets_manager: + secret_arn: arn:aws:secretsmanager:us-east-1:123456789012:secret:my-credentials + region: us-east-1 # optional; falls back to AWS_REGION / instance metadata + refresh_interval: 1h # optional; defaults to 1h + username_key: username # optional; defaults to "username" + password_key: password # optional; defaults to "password" + receivers: otlp: protocols: @@ -79,4 +90,25 @@ service: receivers: [otlp] processors: [] exporters: [otlp_grpc] -``` \ No newline at end of file +``` + +## AWS Secrets Manager + +When `client_auth.aws_secrets_manager` is set, the extension fetches credentials from AWS Secrets Manager at startup and polls for changes at the configured `refresh_interval` (default: `1h`). Credentials are swapped in place when a rotation is detected — no collector restart required. + +The secret must be a JSON object containing the username and password. The default key names match AWS's own rotation templates for RDS, Redshift, DocumentDB, and ElastiCache: + +```json +{ + "username": "myuser", + "password": "mypassword" +} +``` + +Use `username_key` and `password_key` to override the field names if your secret uses different keys. + +**Authentication:** The extension uses the standard AWS SDK credential chain — environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), shared credentials file (`~/.aws/credentials`), IAM instance profile, ECS/EKS task role, etc. No additional configuration is needed if the collector is running on an AWS resource with an appropriate IAM role. + +**Behavior on poll failure:** If a refresh fails (e.g., transient network error, temporary permission issue), the extension logs a warning and continues using the last successfully fetched credentials. The collector is not interrupted. + +`aws_secrets_manager` is mutually exclusive with `username`, `username_file`, `password`, and `password_file`. \ No newline at end of file diff --git a/extension/basicauthextension/aws_secrets_manager_resolver.go b/extension/basicauthextension/aws_secrets_manager_resolver.go new file mode 100644 index 0000000000000..d00fc2edaea3b --- /dev/null +++ b/extension/basicauthextension/aws_secrets_manager_resolver.go @@ -0,0 +1,170 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package basicauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "go.uber.org/zap" +) + +type secretsManagerClient interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +// secretCredentials holds a consistent username/password pair fetched from a single secret. +// Stored as a single atomic pointer to guarantee HTTP clients always read a matched pair. +type secretCredentials struct { + username string + password string +} + +// awsSecretsManagerResolver fetches a JSON secret from AWS Secrets Manager and polls +// for changes at a configurable interval, updating credentials in place without restarting +// the collector. +type awsSecretsManagerResolver struct { + cfg *AWSSecretsManagerSettings + client secretsManagerClient + creds atomic.Pointer[secretCredentials] + onChange func() + shutdownCh chan struct{} + doneCh chan struct{} + logger *zap.Logger +} + +func newAWSSecretsManagerResolver(cfg *AWSSecretsManagerSettings, logger *zap.Logger, onChange func()) *awsSecretsManagerResolver { + return &awsSecretsManagerResolver{ + cfg: cfg, + logger: logger, + onChange: onChange, + } +} + +func (r *awsSecretsManagerResolver) start(ctx context.Context) error { + opts := []func(*awsconfig.LoadOptions) error{} + if r.cfg.Region != "" { + opts = append(opts, awsconfig.WithRegion(r.cfg.Region)) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + r.client = secretsmanager.NewFromConfig(cfg) + return r.startWithClient(ctx) +} + +func (r *awsSecretsManagerResolver) startWithClient(ctx context.Context) error { + if r.shutdownCh != nil { + return errors.New("already started") + } + if err := r.fetch(ctx); err != nil { + return fmt.Errorf("initial fetch from AWS Secrets Manager failed: %w", err) + } + + r.shutdownCh = make(chan struct{}) + r.doneCh = make(chan struct{}) + go r.poll(r.cfg.refreshInterval()) + return nil +} + +func (r *awsSecretsManagerResolver) shutdown() { + if r.shutdownCh != nil { + close(r.shutdownCh) + <-r.doneCh + r.shutdownCh = nil + } +} + +func (r *awsSecretsManagerResolver) Username() string { + if c := r.creds.Load(); c != nil { + return c.username + } + return "" +} + +func (r *awsSecretsManagerResolver) Password() string { + if c := r.creds.Load(); c != nil { + return c.password + } + return "" +} + +func (r *awsSecretsManagerResolver) poll(interval time.Duration) { + defer close(r.doneCh) + + // cancelCtx lets in-flight AWS calls be interrupted when shutdown is requested, + // avoiding a long block waiting for the SDK timeout. + // Capture the channel value (not the field) to avoid a race with shutdown() + // zeroing r.shutdownCh after doneCh is closed. + shutdownCh := r.shutdownCh + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + <-shutdownCh + cancel() + }() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-r.shutdownCh: + return + case <-ticker.C: + if err := r.fetch(ctx); err != nil { + r.logger.Warn("failed to refresh credentials from AWS Secrets Manager, keeping last known values", + zap.String("secret_arn", r.cfg.SecretARN), + zap.Error(err)) + } + } + } +} + +func (r *awsSecretsManagerResolver) fetch(ctx context.Context) error { + resp, err := r.client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(r.cfg.SecretARN), + }) + if err != nil { + return fmt.Errorf("GetSecretValue: %w", err) + } + if resp.SecretString == nil { + return fmt.Errorf("secret %q has no string value", r.cfg.SecretARN) + } + + var fields map[string]string + if err := json.Unmarshal([]byte(*resp.SecretString), &fields); err != nil { + return fmt.Errorf("unmarshal secret JSON: %w", err) + } + + usernameKey := r.cfg.usernameKey() + passwordKey := r.cfg.passwordKey() + + newUsername, ok := fields[usernameKey] + if !ok { + return fmt.Errorf("key %q not found in secret %q", usernameKey, r.cfg.SecretARN) + } + newPassword, ok := fields[passwordKey] + if !ok { + return fmt.Errorf("key %q not found in secret %q", passwordKey, r.cfg.SecretARN) + } + + // Store both credentials atomically so HTTP clients always read a matched pair. + old := r.creds.Load() + if old != nil && old.username == newUsername && old.password == newPassword { + return nil + } + r.creds.Store(&secretCredentials{username: newUsername, password: newPassword}) + if r.onChange != nil { + r.onChange() + } + return nil +} diff --git a/extension/basicauthextension/aws_secrets_manager_resolver_test.go b/extension/basicauthextension/aws_secrets_manager_resolver_test.go new file mode 100644 index 0000000000000..1c08ddce52884 --- /dev/null +++ b/extension/basicauthextension/aws_secrets_manager_resolver_test.go @@ -0,0 +1,252 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package basicauthextension + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// mockSecretsManagerClient is a thread-safe mock for secretsManagerClient. +type mockSecretsManagerClient struct { + output atomic.Pointer[secretsmanager.GetSecretValueOutput] + callErr atomic.Pointer[error] + callCount atomic.Int32 +} + +func newMockClient(username, password string) *mockSecretsManagerClient { + m := &mockSecretsManagerClient{} + m.setCredentials(username, password) + return m +} + +func (m *mockSecretsManagerClient) setCredentials(username, password string) { + data, _ := json.Marshal(map[string]string{"username": username, "password": password}) + m.output.Store(&secretsmanager.GetSecretValueOutput{SecretString: aws.String(string(data))}) +} + +func (m *mockSecretsManagerClient) setError(err error) { + m.callErr.Store(&err) +} + +func (m *mockSecretsManagerClient) GetSecretValue(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + m.callCount.Add(1) + if p := m.callErr.Load(); p != nil { + return nil, *p + } + return m.output.Load(), nil +} + +func newTestResolver(t *testing.T, cfg *AWSSecretsManagerSettings, onChange func()) *awsSecretsManagerResolver { + t.Helper() + if cfg == nil { + cfg = &AWSSecretsManagerSettings{SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test"} + } + return newAWSSecretsManagerResolver(cfg, zaptest.NewLogger(t), onChange) +} + +func TestAWSSecretsManagerResolver_InitialFetch(t *testing.T) { + mock := newMockClient("alice", "s3cr3t") + r := newTestResolver(t, nil, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + assert.Equal(t, "alice", r.Username()) + assert.Equal(t, "s3cr3t", r.Password()) +} + +func TestAWSSecretsManagerResolver_CredentialsAreAtomicPair(t *testing.T) { + // Verify that Username() and Password() always return a consistent pair + // by checking they come from the same credentials struct load. + mock := newMockClient("alice", "old-pass") + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + RefreshInterval: 5 * time.Millisecond, + }, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + // Read many times while rotation is in progress; should never see a split pair. + done := make(chan struct{}) + go func() { + defer close(done) + for range 500 { + c := r.creds.Load() + if c == nil { + continue + } + // A torn pair would have mismatched username/password from different fetches. + // Since we only ever store "alice"/"old-pass" or "alice"/"new-pass", + // any mismatch indicates a torn read. + assert.True(t, + (c.username == "alice" && c.password == "old-pass") || + (c.username == "alice" && c.password == "new-pass"), + "torn credential pair: username=%q password=%q", c.username, c.password) + } + }() + mock.setCredentials("alice", "new-pass") + <-done +} + +func TestAWSSecretsManagerResolver_RotationDetected(t *testing.T) { + mock := newMockClient("alice", "old-pass") + var onChangeCalled atomic.Int32 + onChange := func() { onChangeCalled.Add(1) } + + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + RefreshInterval: 10 * time.Millisecond, + }, onChange) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + // onChange fired once on initial fetch + assert.Equal(t, int32(1), onChangeCalled.Load()) + + mock.setCredentials("alice", "new-pass") + + assert.Eventually(t, func() bool { + return r.Password() == "new-pass" + }, 2*time.Second, 10*time.Millisecond) + + assert.Greater(t, onChangeCalled.Load(), int32(1)) +} + +func TestAWSSecretsManagerResolver_NoOnChangeWhenUnchanged(t *testing.T) { + mock := newMockClient("alice", "s3cr3t") + var onChangeCalled atomic.Int32 + onChange := func() { onChangeCalled.Add(1) } + + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + RefreshInterval: 10 * time.Millisecond, + }, onChange) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + initialCalls := onChangeCalled.Load() + + // Wait for at least 3 more polls to confirm onChange doesn't fire on stable credentials. + assert.Eventually(t, func() bool { + return mock.callCount.Load() >= initialCalls+3 + }, 2*time.Second, 10*time.Millisecond) + + assert.Equal(t, initialCalls, onChangeCalled.Load(), "onChange should not fire when credentials are unchanged") +} + +func TestAWSSecretsManagerResolver_PollErrorKeepsLastValue(t *testing.T) { + mock := newMockClient("alice", "s3cr3t") + + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + RefreshInterval: 10 * time.Millisecond, + }, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + countAfterStart := mock.callCount.Load() + mock.setError(errors.New("network error")) + + // Wait until the error path has been exercised at least twice. + assert.Eventually(t, func() bool { + return mock.callCount.Load() >= countAfterStart+2 + }, 2*time.Second, 10*time.Millisecond) + + // Last known values must be preserved despite errors. + assert.Equal(t, "alice", r.Username()) + assert.Equal(t, "s3cr3t", r.Password()) +} + +func TestAWSSecretsManagerResolver_InitialFetchError(t *testing.T) { + mock := &mockSecretsManagerClient{} + mock.setError(errors.New("permission denied")) + r := newTestResolver(t, nil, nil) + r.client = mock + + err := r.startWithClient(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "initial fetch from AWS Secrets Manager failed") +} + +func TestAWSSecretsManagerResolver_AlreadyStarted(t *testing.T) { + mock := newMockClient("alice", "s3cr3t") + r := newTestResolver(t, nil, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + err := r.startWithClient(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "already started") +} + +func TestAWSSecretsManagerResolver_MissingKey(t *testing.T) { + data, _ := json.Marshal(map[string]string{"user": "alice", "pass": "s3cr3t"}) + mock := &mockSecretsManagerClient{} + mock.output.Store(&secretsmanager.GetSecretValueOutput{SecretString: aws.String(string(data))}) + + r := newTestResolver(t, nil, nil) + r.client = mock + + err := r.startWithClient(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), `key "username" not found`) +} + +func TestAWSSecretsManagerResolver_CustomKeys(t *testing.T) { + data, _ := json.Marshal(map[string]string{"user": "alice", "pass": "s3cr3t"}) + mock := &mockSecretsManagerClient{} + mock.output.Store(&secretsmanager.GetSecretValueOutput{SecretString: aws.String(string(data))}) + + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + UsernameKey: "user", + PasswordKey: "pass", + }, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + defer r.shutdown() + + assert.Equal(t, "alice", r.Username()) + assert.Equal(t, "s3cr3t", r.Password()) +} + +func TestAWSSecretsManagerResolver_ShutdownStopsPolling(t *testing.T) { + mock := newMockClient("alice", "s3cr3t") + + r := newTestResolver(t, &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + RefreshInterval: 5 * time.Millisecond, + }, nil) + r.client = mock + + require.NoError(t, r.startWithClient(t.Context())) + r.shutdown() + + countAfterShutdown := mock.callCount.Load() + time.Sleep(30 * time.Millisecond) + assert.Equal(t, countAfterShutdown, mock.callCount.Load(), "no calls should occur after shutdown") +} diff --git a/extension/basicauthextension/config.go b/extension/basicauthextension/config.go index a2628c9efb260..bb9c05cfb0369 100644 --- a/extension/basicauthextension/config.go +++ b/extension/basicauthextension/config.go @@ -5,10 +5,13 @@ package basicauthextension // import "github.com/open-telemetry/opentelemetry-co import ( "errors" + "time" "go.opentelemetry.io/collector/config/configopaque" ) +const defaultRefreshInterval = time.Hour + var ( errNoCredentialSource = errors.New("no credential source provided") errMultipleAuthenticators = errors.New("only one of `htpasswd` or `client_auth` can be specified") @@ -23,6 +26,45 @@ type HtpasswdSettings struct { _ struct{} } +// AWSSecretsManagerSettings configures credential retrieval from AWS Secrets Manager. +// The secret must be a JSON object containing the username and password fields. +// Credentials are polled at the configured interval and rotated in place without restarting the collector. +type AWSSecretsManagerSettings struct { + // SecretARN is the ARN (or name) of the secret in AWS Secrets Manager. Required. + SecretARN string `mapstructure:"secret_arn"` + // Region is the AWS region. If empty, the SDK default chain is used (AWS_REGION env var, instance metadata, etc.). + Region string `mapstructure:"region"` + // RefreshInterval controls how often the secret is polled for changes. Defaults to 1h. + RefreshInterval time.Duration `mapstructure:"refresh_interval"` + // UsernameKey is the JSON key for the username field. Defaults to "username". + UsernameKey string `mapstructure:"username_key"` + // PasswordKey is the JSON key for the password field. Defaults to "password". + PasswordKey string `mapstructure:"password_key"` + // prevent unkeyed literal initialization + _ struct{} +} + +func (s *AWSSecretsManagerSettings) usernameKey() string { + if s.UsernameKey != "" { + return s.UsernameKey + } + return "username" +} + +func (s *AWSSecretsManagerSettings) passwordKey() string { + if s.PasswordKey != "" { + return s.PasswordKey + } + return "password" +} + +func (s *AWSSecretsManagerSettings) refreshInterval() time.Duration { + if s.RefreshInterval > 0 { + return s.RefreshInterval + } + return defaultRefreshInterval +} + type ClientAuthSettings struct { // Username holds the username to use for client authentication. Username string `mapstructure:"username"` @@ -34,9 +76,25 @@ type ClientAuthSettings struct { // PasswordFile points to a file that contains the password. // If set, takes precedence over Password. The file is watched for changes. PasswordFile string `mapstructure:"password_file"` + // AWSSecretsManager configures credential retrieval from AWS Secrets Manager. + // Mutually exclusive with Username, UsernameFile, Password, and PasswordFile. + AWSSecretsManager *AWSSecretsManagerSettings `mapstructure:"aws_secrets_manager,omitempty"` // prevent unkeyed literal initialization _ struct{} } + +func (c *ClientAuthSettings) Validate() error { + if c.AWSSecretsManager != nil { + if c.Username != "" || c.UsernameFile != "" || string(c.Password) != "" || c.PasswordFile != "" { + return errors.New("aws_secrets_manager cannot be combined with username, username_file, password, or password_file") + } + if c.AWSSecretsManager.SecretARN == "" { + return errors.New("aws_secrets_manager.secret_arn is required") + } + } + return nil +} + type Config struct { // Htpasswd settings. Htpasswd *HtpasswdSettings `mapstructure:"htpasswd,omitempty"` @@ -59,5 +117,9 @@ func (cfg *Config) Validate() error { return errNoCredentialSource } + if clientCondition { + return cfg.ClientAuth.Validate() + } + return nil } diff --git a/extension/basicauthextension/config.schema.yaml b/extension/basicauthextension/config.schema.yaml index c56d601f18928..74488b400d53f 100644 --- a/extension/basicauthextension/config.schema.yaml +++ b/extension/basicauthextension/config.schema.yaml @@ -1,7 +1,31 @@ $defs: + aws_secrets_manager_settings: + description: AWSSecretsManagerSettings configures credential retrieval from AWS Secrets Manager. The secret must be a JSON object containing the username and password fields. Credentials are polled at the configured interval and rotated in place without restarting the collector. + type: object + properties: + password_key: + description: PasswordKey is the JSON key for the password field. Defaults to "password". + type: string + refresh_interval: + description: RefreshInterval controls how often the secret is polled for changes. Defaults to 1h. + type: string + format: duration + region: + description: Region is the AWS region. If empty, the SDK default chain is used (AWS_REGION env var, instance metadata, etc.). + type: string + secret_arn: + description: SecretARN is the ARN (or name) of the secret in AWS Secrets Manager. Required. + type: string + username_key: + description: UsernameKey is the JSON key for the username field. Defaults to "username". + type: string client_auth_settings: type: object properties: + aws_secrets_manager: + description: AWSSecretsManager configures credential retrieval from AWS Secrets Manager. Mutually exclusive with Username, UsernameFile, Password, and PasswordFile. + x-pointer: true + $ref: aws_secrets_manager_settings password: description: Password holds the password to use for client authentication. $ref: go.opentelemetry.io/collector/config/configopaque.string diff --git a/extension/basicauthextension/config_test.go b/extension/basicauthextension/config_test.go index cb9e5a153e60e..8069f244bd153 100644 --- a/extension/basicauthextension/config_test.go +++ b/extension/basicauthextension/config_test.go @@ -16,6 +16,81 @@ import ( "github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension/internal/metadata" ) +func TestClientAuthSettingsValidate(t *testing.T) { + tests := []struct { + name string + cfg ClientAuthSettings + wantErr string + }{ + { + name: "aws_secrets_manager only is valid", + cfg: ClientAuthSettings{ + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + }, + { + name: "aws_secrets_manager missing secret_arn", + cfg: ClientAuthSettings{ + AWSSecretsManager: &AWSSecretsManagerSettings{}, + }, + wantErr: "aws_secrets_manager.secret_arn is required", + }, + { + name: "aws_secrets_manager combined with username", + cfg: ClientAuthSettings{ + Username: "user", + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + wantErr: "aws_secrets_manager cannot be combined with", + }, + { + name: "aws_secrets_manager combined with password", + cfg: ClientAuthSettings{ + Password: "pass", + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + wantErr: "aws_secrets_manager cannot be combined with", + }, + { + name: "aws_secrets_manager combined with username_file", + cfg: ClientAuthSettings{ + UsernameFile: "/etc/secrets/user", + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + wantErr: "aws_secrets_manager cannot be combined with", + }, + { + name: "aws_secrets_manager combined with password_file", + cfg: ClientAuthSettings{ + PasswordFile: "/etc/secrets/pass", + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + wantErr: "aws_secrets_manager cannot be combined with", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + func TestLoadConfig(t *testing.T) { t.Parallel() diff --git a/extension/basicauthextension/extension.go b/extension/basicauthextension/extension.go index 16525e87d7454..7868474adbc9f 100644 --- a/extension/basicauthextension/extension.go +++ b/extension/basicauthextension/extension.go @@ -218,11 +218,12 @@ var ( ) type basicAuthClient struct { - clientAuth *ClientAuthSettings - logger *zap.Logger - usernameResolver credentialsfile.ValueResolver - passwordResolver credentialsfile.ValueResolver - grpcMetadata atomic.Pointer[map[string]string] + clientAuth *ClientAuthSettings + logger *zap.Logger + usernameResolver credentialsfile.ValueResolver + passwordResolver credentialsfile.ValueResolver + awsSecretsManager *awsSecretsManagerResolver + grpcMetadata atomic.Pointer[map[string]string] } func (ba *basicAuthClient) updateGRPCMetadata() { @@ -237,8 +238,19 @@ func (ba *basicAuthClient) Start(ctx context.Context, _ component.Host) error { if ba.clientAuth == nil { return errNoCredentialSource } - onChange := func(_ string) { ba.updateGRPCMetadata() } ca := ba.clientAuth + + if ca.AWSSecretsManager != nil { + r := newAWSSecretsManagerResolver(ca.AWSSecretsManager, ba.logger, ba.updateGRPCMetadata) + if err := r.start(ctx); err != nil { + return err + } + ba.awsSecretsManager = r + // gRPC metadata is updated by the onChange callback fired during the initial fetch. + return nil + } + + onChange := func(_ string) { ba.updateGRPCMetadata() } if ca.Username != "" || ca.UsernameFile != "" { r, err := credentialsfile.NewValueResolver(ca.Username, ca.UsernameFile, ba.logger, credentialsfile.WithOnChange(onChange)) if err != nil { @@ -265,6 +277,9 @@ func (ba *basicAuthClient) Start(ctx context.Context, _ component.Host) error { func (ba *basicAuthClient) Shutdown(_ context.Context) error { var errs []error + if ba.awsSecretsManager != nil { + ba.awsSecretsManager.shutdown() + } if ba.usernameResolver != nil { errs = append(errs, ba.usernameResolver.Shutdown()) } @@ -275,6 +290,9 @@ func (ba *basicAuthClient) Shutdown(_ context.Context) error { } func (ba *basicAuthClient) username() string { + if ba.awsSecretsManager != nil { + return ba.awsSecretsManager.Username() + } if ba.usernameResolver != nil { return ba.usernameResolver.Value() } @@ -285,6 +303,9 @@ func (ba *basicAuthClient) username() string { } func (ba *basicAuthClient) password() string { + if ba.awsSecretsManager != nil { + return ba.awsSecretsManager.Password() + } if ba.passwordResolver != nil { return ba.passwordResolver.Value() } diff --git a/extension/basicauthextension/extension_test.go b/extension/basicauthextension/extension_test.go index afae1d6e7e3d1..dce2ea47fe18b 100644 --- a/extension/basicauthextension/extension_test.go +++ b/extension/basicauthextension/extension_test.go @@ -296,3 +296,42 @@ func TestBasicAuth_ClientInvalid(t *testing.T) { assert.Error(t, err) }) } + +func TestBasicAuth_ClientAWSSecretsManager(t *testing.T) { + mock := newMockClient("awsuser", "awspass") + + ext := newClientAuthExtension(&Config{ + ClientAuth: &ClientAuthSettings{ + AWSSecretsManager: &AWSSecretsManagerSettings{ + SecretARN: "arn:aws:secretsmanager:us-east-1:123:secret:test", + }, + }, + }) + // Inject the mock client before Start so we bypass real AWS calls. + ext.awsSecretsManager = newAWSSecretsManagerResolver( + ext.clientAuth.AWSSecretsManager, + ext.logger, + ext.updateGRPCMetadata, + ) + ext.awsSecretsManager.client = mock + require.NoError(t, ext.awsSecretsManager.startWithClient(t.Context())) + // updateGRPCMetadata is called by onChange during startWithClient. + + authCreds := base64.StdEncoding.EncodeToString([]byte("awsuser:awspass")) + + // HTTP round-tripper uses the credentials. + rt, err := ext.RoundTripper(&mockRoundTripper{}) + require.NoError(t, err) + resp, err := rt.RoundTrip(&http.Request{Header: http.Header{}}) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Basic %s", authCreds), resp.Header.Get("Authorization")) + + // gRPC credentials use the same credentials. + cred, err := ext.PerRPCCredentials() + require.NoError(t, err) + md, err := cred.GetRequestMetadata(t.Context()) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Basic %s", authCreds), md["authorization"]) + + require.NoError(t, ext.Shutdown(t.Context())) +} diff --git a/extension/basicauthextension/go.mod b/extension/basicauthextension/go.mod index c2320b969b6ed..4a5c53f3ba3ed 100644 --- a/extension/basicauthextension/go.mod +++ b/extension/basicauthextension/go.mod @@ -3,6 +3,9 @@ module github.com/open-telemetry/opentelemetry-collector-contrib/extension/basic go 1.25.0 require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile v0.152.0 github.com/stretchr/testify v1.11.1 github.com/tg123/go-htpasswd v1.2.4 @@ -22,6 +25,18 @@ require ( require ( github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect diff --git a/extension/basicauthextension/go.sum b/extension/basicauthextension/go.sum index 9d37fb4425dd2..0da385828588a 100644 --- a/extension/basicauthextension/go.sum +++ b/extension/basicauthextension/go.sum @@ -1,5 +1,35 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 h1:JUGKqUnJHbXpS8uyuICP/zpQ+vXUIXW2zTEqjMLCqrY= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7/go.mod h1:l/cqI7ujYqBuTR6Ll13d9/gG/uUdlVzJ1UDltEEBTOo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=