This is an automated email from the ASF dual-hosted git repository.

laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 08af1ee6d fix(aws): reject incomplete static credential configuration 
(#1574)
08af1ee6d is described below

commit 08af1ee6d679ae9db021612464b7dd7f0038c62b
Author: Minh Vu <[email protected]>
AuthorDate: Mon Aug 3 17:56:56 2026 +0200

    fix(aws): reject incomplete static credential configuration (#1574)
    
    ## What changed
    
    Reject incomplete static AWS credential properties in Glue and S3
    configuration. Access key and secret key must now be provided together,
    and a session token cannot be configured without that pair.
    
    ## Why
    
    Providing any single credential field previously replaced the default
    AWS provider chain with invalid static credentials containing blank
    fields. This delayed a configuration error until an AWS request and
    could obscure the actual typo.
    
    Complete key pairs, with or without a session token, continue to use the
    static provider.
    
    ## Testing
    
    - `go test ./catalog/glue -run
    TestLoadAWSConfigRejectsIncompleteStaticCredentials`
    - `go test ./io/gocloud -run
    
'TestParseAWSConfigRejectsIncompleteStaticCredentials|TestResolveS3AWSConfigCredentialPrecedence'`
    - `go vet ./catalog/glue ./io/gocloud`
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 catalog/glue/glue.go              |  6 +++-
 catalog/glue/glue_test.go         | 34 ++++++++++++++++++++
 internal/awsconfig/credentials.go | 38 +++++++++++++++++++++++
 io/gocloud/s3.go                  | 19 +++++++++---
 io/gocloud/s3_test.go             | 65 ++++++++++++++++++++++++++++++++++++---
 5 files changed, 151 insertions(+), 11 deletions(-)

diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index 171b62b9e..02615f0a0 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -32,6 +32,7 @@ import (
        "github.com/apache/iceberg-go"
        "github.com/apache/iceberg-go/catalog"
        "github.com/apache/iceberg-go/catalog/internal"
+       internalaws "github.com/apache/iceberg-go/internal/awsconfig"
        "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/metrics"
        "github.com/apache/iceberg-go/table"
@@ -123,7 +124,10 @@ func toAwsConfig(ctx context.Context, p 
iceberg.Properties) (aws.Config, error)
        }
 
        key, secret, token := p[AccessKeyID], p[SecretAccessKey], 
p[SessionToken]
-       if key != "" || secret != "" || token != "" {
+       if err := internalaws.ValidateStaticCredentials(AccessKeyID, 
SecretAccessKey, SessionToken, key, secret, token); err != nil {
+               return aws.Config{}, err
+       }
+       if key != "" {
                opts = append(opts, config.WithCredentialsProvider(
                        credentials.NewStaticCredentialsProvider(key, secret, 
token)))
        }
diff --git a/catalog/glue/glue_test.go b/catalog/glue/glue_test.go
index 3fd0fa237..c0a09a24c 100644
--- a/catalog/glue/glue_test.go
+++ b/catalog/glue/glue_test.go
@@ -50,6 +50,40 @@ import (
 
 var errGluePurgeRemove = errors.New("glue purge remove failed")
 
+func TestLoadAWSConfigRejectsIncompleteStaticCredentials(t *testing.T) {
+       t.Parallel()
+
+       tests := []struct {
+               name  string
+               props iceberg.Properties
+               err   string
+       }{
+               {"access key only", iceberg.Properties{AccessKeyID: "access"}, 
"glue.access-key-id and glue.secret-access-key must be configured together"},
+               {"secret key only", iceberg.Properties{SecretAccessKey: 
"secret"}, "glue.access-key-id and glue.secret-access-key must be configured 
together"},
+               {"session token only", iceberg.Properties{SessionToken: 
"token"}, "glue.session-token requires glue.access-key-id and 
glue.secret-access-key"},
+               {"access key and token", iceberg.Properties{AccessKeyID: 
"access", SessionToken: "token"}, "glue.access-key-id and 
glue.secret-access-key must be configured together"},
+               {"secret key and token", iceberg.Properties{SecretAccessKey: 
"secret", SessionToken: "token"}, "glue.access-key-id and 
glue.secret-access-key must be configured together"},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       _, err := toAwsConfig(context.Background(), tt.props)
+                       require.ErrorContains(t, err, tt.err)
+               })
+       }
+
+       cfg, err := toAwsConfig(context.Background(), iceberg.Properties{
+               AccessKeyID: "access", SecretAccessKey: "secret", SessionToken: 
"token",
+       })
+       require.NoError(t, err)
+       creds, err := cfg.Credentials.Retrieve(context.Background())
+       require.NoError(t, err)
+       require.Equal(t, "access", creds.AccessKeyID)
+       require.Equal(t, "secret", creds.SecretAccessKey)
+       require.Equal(t, "token", creds.SessionToken)
+}
+
 type mockGlueClient struct {
        mock.Mock
 }
diff --git a/internal/awsconfig/credentials.go 
b/internal/awsconfig/credentials.go
new file mode 100644
index 000000000..513ef9840
--- /dev/null
+++ b/internal/awsconfig/credentials.go
@@ -0,0 +1,38 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package awsconfig
+
+import (
+       "errors"
+       "fmt"
+)
+
+var ErrIncompleteStaticCredentials = errors.New("incomplete static AWS 
credentials")
+
+// ValidateStaticCredentials ensures a session token is only configured with
+// the complete key pair required by AWS static credential providers.
+func ValidateStaticCredentials(keyName, secretName, tokenName, key, secret, 
token string) error {
+       if key == "" && secret == "" && token != "" {
+               return fmt.Errorf("%w: %s requires %s and %s", 
ErrIncompleteStaticCredentials, tokenName, keyName, secretName)
+       }
+       if (key == "") != (secret == "") {
+               return fmt.Errorf("%w: %s and %s must be configured together", 
ErrIncompleteStaticCredentials, keyName, secretName)
+       }
+
+       return nil
+}
diff --git a/io/gocloud/s3.go b/io/gocloud/s3.go
index eaa190a9f..39e978e1e 100644
--- a/io/gocloud/s3.go
+++ b/io/gocloud/s3.go
@@ -28,6 +28,7 @@ import (
        "strconv"
        "time"
 
+       internalaws "github.com/apache/iceberg-go/internal/awsconfig"
        "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/utils"
        "github.com/aws/aws-sdk-go-v2/aws"
@@ -68,9 +69,12 @@ func ParseAWSConfig(ctx context.Context, props 
map[string]string) (*aws.Config,
 
        accessKey, secretAccessKey := props[io.S3AccessKeyID], 
props[io.S3SecretAccessKey]
        token := props[io.S3SessionToken]
-       if accessKey != "" || secretAccessKey != "" || token != "" {
+       if err := internalaws.ValidateStaticCredentials(io.S3AccessKeyID, 
io.S3SecretAccessKey, io.S3SessionToken, accessKey, secretAccessKey, token); 
err != nil {
+               return nil, err
+       }
+       if accessKey != "" {
                opts = append(opts, 
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
-                       props[io.S3AccessKeyID], props[io.S3SecretAccessKey], 
props[io.S3SessionToken],
+                       accessKey, secretAccessKey, token,
                )))
        }
 
@@ -174,6 +178,13 @@ func resolveUsePathStyle(endpoint string, props 
map[string]string) bool {
 // resolveS3AWSConfig returns the AWS config for the S3 FileIO, preferring an
 // ambient context config but letting explicit s3.* credentials override it.
 func resolveS3AWSConfig(ctx context.Context, props map[string]string) 
(*aws.Config, error) {
+       if err := internalaws.ValidateStaticCredentials(
+               io.S3AccessKeyID, io.S3SecretAccessKey, io.S3SessionToken,
+               props[io.S3AccessKeyID], props[io.S3SecretAccessKey], 
props[io.S3SessionToken],
+       ); err != nil {
+               return nil, err
+       }
+
        var (
                base *aws.Config
                err  error
@@ -195,9 +206,7 @@ func resolveS3AWSConfig(ctx context.Context, props 
map[string]string) (*aws.Conf
                cfg.Region = r
        }
 
-       // A complete explicit key pair overrides the credentials. A partial set
-       // (missing the access key or secret) falls through to the 
context/default
-       // chain rather than installing a provider with blank fields.
+       // A complete explicit key pair overrides the credentials.
        if props[io.S3AccessKeyID] != "" && props[io.S3SecretAccessKey] != "" {
                cfg.Credentials = credentials.NewStaticCredentialsProvider(
                        props[io.S3AccessKeyID], props[io.S3SecretAccessKey], 
props[io.S3SessionToken],
diff --git a/io/gocloud/s3_test.go b/io/gocloud/s3_test.go
index 1f352c9b2..61a5abd54 100644
--- a/io/gocloud/s3_test.go
+++ b/io/gocloud/s3_test.go
@@ -26,6 +26,7 @@ import (
        "testing"
        "time"
 
+       internalaws "github.com/apache/iceberg-go/internal/awsconfig"
        "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/utils"
        "github.com/aws/aws-sdk-go-v2/aws"
@@ -95,12 +96,12 @@ func TestResolveS3AWSConfigCredentialPrecedence(t 
*testing.T) {
                assert.Nil(t, shared.HTTPClient, "shared ctx config must stay 
unmutated")
        })
 
-       // A partial key set (missing the secret) must not clobber the context 
creds.
-       t.Run("partial props creds fall through", func(t *testing.T) {
+       // A partial key set must fail consistently, even with ambient 
credentials.
+       t.Run("partial props creds are rejected", func(t *testing.T) {
                t.Parallel()
-               cfg, err := resolveS3AWSConfig(ctxWith, 
map[string]string{io.S3AccessKeyID: "PARTIAL"})
-               require.NoError(t, err)
-               assert.Equal(t, "CTX", retrieve(t, cfg), "incomplete override 
must keep ctx creds")
+               _, err := resolveS3AWSConfig(ctxWith, 
map[string]string{io.S3AccessKeyID: "PARTIAL"})
+               require.ErrorIs(t, err, 
internalaws.ErrIncompleteStaticCredentials)
+               require.ErrorContains(t, err, "s3.access-key-id and 
s3.secret-access-key must be configured together")
        })
 
        // Explicit region overrides the context config's region, without 
mutating it.
@@ -114,6 +115,60 @@ func TestResolveS3AWSConfigCredentialPrecedence(t 
*testing.T) {
        })
 }
 
+func TestParseAWSConfigRejectsIncompleteStaticCredentials(t *testing.T) {
+       t.Parallel()
+
+       tests := []struct {
+               name  string
+               props map[string]string
+               err   string
+       }{
+               {"access key only", map[string]string{io.S3AccessKeyID: 
"access"}, "s3.access-key-id and s3.secret-access-key must be configured 
together"},
+               {"secret key only", map[string]string{io.S3SecretAccessKey: 
"secret"}, "s3.access-key-id and s3.secret-access-key must be configured 
together"},
+               {"session token only", map[string]string{io.S3SessionToken: 
"token"}, "s3.session-token requires s3.access-key-id and 
s3.secret-access-key"},
+               {"access key and token", map[string]string{io.S3AccessKeyID: 
"access", io.S3SessionToken: "token"}, "s3.access-key-id and 
s3.secret-access-key must be configured together"},
+               {"secret key and token", 
map[string]string{io.S3SecretAccessKey: "secret", io.S3SessionToken: "token"}, 
"s3.access-key-id and s3.secret-access-key must be configured together"},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       _, err := ParseAWSConfig(context.Background(), tt.props)
+                       require.ErrorIs(t, err, 
internalaws.ErrIncompleteStaticCredentials)
+                       require.ErrorContains(t, err, tt.err)
+               })
+       }
+
+       t.Run("empty props use the default chain", func(t *testing.T) {
+               t.Parallel()
+               cfg, err := ParseAWSConfig(context.Background(), 
map[string]string{})
+               require.NoError(t, err)
+               _, static := 
cfg.Credentials.(credentials.StaticCredentialsProvider)
+               assert.False(t, static)
+       })
+
+       t.Run("key pair without token is accepted", func(t *testing.T) {
+               t.Parallel()
+               cfg, err := ParseAWSConfig(context.Background(), 
map[string]string{
+                       io.S3AccessKeyID: "access", io.S3SecretAccessKey: 
"secret",
+               })
+               require.NoError(t, err)
+               creds, err := cfg.Credentials.Retrieve(context.Background())
+               require.NoError(t, err)
+               assert.Empty(t, creds.SessionToken)
+       })
+
+       cfg, err := ParseAWSConfig(context.Background(), map[string]string{
+               io.S3AccessKeyID: "access", io.S3SecretAccessKey: "secret", 
io.S3SessionToken: "token",
+       })
+       require.NoError(t, err)
+       creds, err := cfg.Credentials.Retrieve(context.Background())
+       require.NoError(t, err)
+       require.Equal(t, "access", creds.AccessKeyID)
+       require.Equal(t, "secret", creds.SecretAccessKey)
+       require.Equal(t, "token", creds.SessionToken)
+}
+
 func TestParseAWSConfigRemoteSigningEnabled(t *testing.T) {
        t.Parallel()
 

Reply via email to