remote_storage/s3: use the AWS default credential chain when a source is configured (#11881)

* remote_storage/s3: use the AWS default credential chain when a source is configured

Type s3 forced anonymous credentials whenever no access/secret key was
set, so IRSA, container credentials, profiles and env keys were never
consulted. Keep anonymous (public bucket) access only when the environment
names no AWS credential source.

* remote_storage/s3: also recognize shared-config and default-profile env sources

* remote_storage/s3: resolve env credentials on their own transport

A guarded HTTP client is for the S3 endpoint; the remote credential
providers it would also serve reach link-local addresses that guard must
not dial. Profiles load shared config, and a test resolves env keys.

* remote_storage/s3: clear every detected credential source in the test

* remote_storage/s3: resolve the full default chain on the guarded client

The eager credential session used NewSession, whose chain covers only
env, shared-credentials and ECS/EC2 providers. Web identity and
assume-role profiles resolve through shared config, so IRSA never
worked when a custom HTTP client was pinned. Enable shared config and
carry the configured region so STS can sign assume-role requests.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 20:30:28 -07:00
1 parent df33e81915
commit 364e19a493
2 files changed
+134 -3

No files matched your search

+44 -3
View File
@@ -168,6 +168,27 @@ func S3CompatibleEndpoint(conf *remote_pb.RemoteConf) (string, bool) {
return opt.endpoint, true
}
// awsCredentialSourceConfigured reports whether the environment names a source
// for the AWS SDK default credential chain, such as static keys, a profile,
// IRSA web identity or the ECS/EKS container credentials endpoint.
func awsCredentialSourceConfigured() bool {
for _, name := range []string{
"AWS_ACCESS_KEY_ID",
"AWS_PROFILE",
"AWS_DEFAULT_PROFILE",
"AWS_WEB_IDENTITY_TOKEN_FILE",
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
"AWS_SHARED_CREDENTIALS_FILE",
"AWS_CONFIG_FILE",
} {
if os.Getenv(name) != "" {
return true
}
}
return false
}
// MakeWithHTTPClient builds the client for any S3-SDK-backed remote storage
// type using the supplied *http.Client (or the AWS SDK default when nil).
// Callers that need to pin the dial path against DNS rebinding can pass a
@@ -191,12 +212,32 @@ func MakeWithHTTPClient(conf *remote_pb.RemoteConf, httpClient *http.Client) (re
}
if opt.accessKey != "" && opt.secretKey != "" {
config.Credentials = credentials.NewStaticCredentials(opt.accessKey, opt.secretKey, "")
} else if opt.anonymousWhenNoCreds && opt.accessKey == "" && opt.secretKey == "" {
// Explicitly disable signing for public buckets.
} else if opt.anonymousWhenNoCreds && opt.accessKey == "" && opt.secretKey == "" && !awsCredentialSourceConfigured() {
// Explicitly disable signing for public buckets. When the environment
// names an AWS credential source, leave Credentials nil so the SDK
// default chain (env, web identity, container, shared profile) is used.
config.Credentials = credentials.AnonymousCredentials
} else if config.Credentials == nil && config.HTTPClient != nil {
// Remote credential providers (container endpoints, IMDS, web
// identity) fetch through this HTTP client; the guarded one is for the
// S3 endpoint and blocks the link-local credential endpoints, so the
// default chain resolves on an unguarded client. Shared config stays
// enabled so web identity and profiles resolve here too, and the
// region carries over so STS can sign assume-role requests.
credSess, err := session.NewSessionWithOptions(session.Options{
Config: aws.Config{Region: config.Region, HTTPClient: &http.Client{}},
SharedConfigState: session.SharedConfigEnable,
})
if err != nil {
return nil, fmt.Errorf("create %s credential session: %w", opt.name, err)
}
config.Credentials = credSess.Config.Credentials
}
sess, err := session.NewSession(config)
sess, err := session.NewSessionWithOptions(session.Options{
Config: *config,
SharedConfigState: session.SharedConfigEnable,
})
if err != nil {
return nil, fmt.Errorf("create %s session: %w", opt.name, err)
}
@@ -21,6 +21,7 @@ import (
)
func TestS3MakeUsesAnonymousCredentialsWhenKeysAreEmpty(t *testing.T) {
clearAWSCredentialEnv(t)
maker := s3RemoteStorageMaker{}
conf := &remote_pb.RemoteConf{
Type: "s3",
@@ -40,6 +41,74 @@ func TestS3MakeUsesAnonymousCredentialsWhenKeysAreEmpty(t *testing.T) {
require.Same(t, credentials.AnonymousCredentials, s3Client.Config.Credentials)
}
func clearAWSCredentialEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"AWS_ACCESS_KEY_ID",
"AWS_PROFILE",
"AWS_DEFAULT_PROFILE",
"AWS_WEB_IDENTITY_TOKEN_FILE",
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
"AWS_SHARED_CREDENTIALS_FILE",
"AWS_CONFIG_FILE",
} {
t.Setenv(name, "")
}
}
func TestS3MakeUsesDefaultCredentialChainWhenKeysAreEmptyAndSourceIsConfigured(t *testing.T) {
for name, value := range map[string]string{
"AWS_ACCESS_KEY_ID": "env-access",
"AWS_PROFILE": "some-profile",
"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token",
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://127.0.0.1:1/creds",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/creds",
} {
t.Run(name, func(t *testing.T) {
clearAWSCredentialEnv(t)
t.Setenv(name, value)
t.Setenv("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/test")
remoteClient, err := s3RemoteStorageMaker{}.Make(&remote_pb.RemoteConf{
Type: "s3",
S3Region: "us-east-1",
S3Endpoint: "http://localhost:8333",
S3ForcePathStyle: true,
})
require.NoError(t, err)
s3Client, ok := remoteClient.(*s3RemoteStorageClient).conn.(*awss3.S3)
require.True(t, ok)
require.NotSame(t, credentials.AnonymousCredentials, s3Client.Config.Credentials)
})
}
}
// A caller that pins a custom http.Client still gets the default chain, so
// remote providers resolve on an unguarded client while S3 requests use the
// pinned one.
func TestS3MakeResolvesEnvCredentialsWithCustomHTTPClient(t *testing.T) {
clearAWSCredentialEnv(t)
t.Setenv("AWS_ACCESS_KEY_ID", "env-access")
t.Setenv("AWS_SECRET_ACCESS_KEY", "env-secret")
remoteClient, err := MakeWithHTTPClient(&remote_pb.RemoteConf{
Type: "s3",
S3Region: "us-east-1",
S3Endpoint: "http://localhost:8333",
S3ForcePathStyle: true,
}, &http.Client{})
require.NoError(t, err)
s3Client, ok := remoteClient.(*s3RemoteStorageClient).conn.(*awss3.S3)
require.True(t, ok)
require.NotSame(t, credentials.AnonymousCredentials, s3Client.Config.Credentials)
value, err := s3Client.Config.Credentials.Get()
require.NoError(t, err)
require.Equal(t, "env-access", value.AccessKeyID)
}
func TestS3MakeUsesStaticCredentialsWhenKeysAreProvided(t *testing.T) {
maker := s3RemoteStorageMaker{}
conf := &remote_pb.RemoteConf{
@@ -396,3 +465,24 @@ func TestIsNoSuchKey(t *testing.T) {
require.True(t, isNoSuchKey(fmt.Errorf("download: %w", awserr.New(awss3.ErrCodeNoSuchKey, "missing", nil))))
require.False(t, isNoSuchKey(awserr.New(awss3.ErrCodeNoSuchBucket, "missing", nil)))
}
func TestS3MakeResolvesEnvCredentialsWhenAnonymousDefault(t *testing.T) {
clearAWSCredentialEnv(t)
t.Setenv("AWS_ACCESS_KEY_ID", "env-access")
t.Setenv("AWS_SECRET_ACCESS_KEY", "env-secret")
remoteClient, err := s3RemoteStorageMaker{}.Make(&remote_pb.RemoteConf{
Type: "s3",
S3Region: "us-east-1",
S3Endpoint: "http://localhost:8333",
S3ForcePathStyle: true,
})
require.NoError(t, err)
s3Client, ok := remoteClient.(*s3RemoteStorageClient).conn.(*awss3.S3)
require.True(t, ok)
value, err := s3Client.Config.Credentials.Get()
require.NoError(t, err)
require.Equal(t, "env-access", value.AccessKeyID)
require.Equal(t, "env-secret", value.SecretAccessKey)
}